Aleksander Machniak
2016-05-06 acf633c73bc8df9a5036bc52d7568f4213ab73c7
program/lib/Roundcube/rcube_washtml.php
@@ -96,7 +96,20 @@
        's', 'samp', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
        'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', 'img',
        // form elements
        'button', 'input', 'textarea', 'select', 'option', 'optgroup'
        'button', 'input', 'textarea', 'select', 'option', 'optgroup',
        // SVG
        'svg', 'altglyph', 'altglyphdef', 'altglyphitem', 'animate',
        'animatecolor', 'animatetransform', 'circle', 'clippath', 'defs', 'desc',
        'ellipse', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line',
        'lineargradient', 'marker', 'mask', 'mpath', 'path', 'pattern',
        'polygon', 'polyline', 'radialgradient', 'rect', 'set', 'stop', 'switch', 'symbol',
        'text', 'textpath', 'tref', 'tspan', 'use', 'view', 'vkern', 'filter',
         // SVG Filters
        'feblend', 'fecolormatrix', 'fecomponenttransfer', 'fecomposite',
        'feconvolvematrix', 'fediffuselighting', 'fedisplacementmap',
        'feflood', 'fefunca', 'fefuncb', 'fefuncg', 'fefuncr', 'fegaussianblur',
        'feimage', 'femerge', 'femergenode', 'femorphology', 'feoffset',
        'fespecularlighting', 'fetile', 'feturbulence',
    );
    /* Ignore these HTML tags and their content */
@@ -109,14 +122,41 @@
        'bordercolordark', 'face', 'marginwidth', 'marginheight', 'axis', 'border',
        'abbr', 'char', 'charoff', 'clear', 'compact', 'coords', 'vspace', 'hspace',
        'cellborder', 'size', 'lang', 'dir', 'usemap', 'shape', 'media',
        'background', 'src', 'poster', 'href',
        // attributes of form elements
        'type', 'rows', 'cols', 'disabled', 'readonly', 'checked', 'multiple', 'value'
        'type', 'rows', 'cols', 'disabled', 'readonly', 'checked', 'multiple', 'value',
        // SVG
        'accent-height', 'accumulate', 'additive', 'alignment-baseline', 'alphabetic',
        'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseprofile',
        'baseline-shift', 'begin', 'bias', 'by', 'clip', 'clip-path', 'clip-rule',
        'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile',
        'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction',
        'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'fill', 'fill-opacity',
        'fill-rule', 'filter', 'flood-color', 'flood-opacity', 'font-family', 'font-size',
        'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'from',
        'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform',
        'image-rendering', 'in', 'in2', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints',
        'keysplines', 'keytimes', 'lengthadjust', 'letter-spacing', 'kernelmatrix',
        'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid',
        'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits',
        'maskunits', 'max', 'mask', 'mode', 'min', 'numoctaves', 'offset', 'operator',
        'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order',
        'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits',
        'points', 'preservealpha', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount',
        'repeatdur', 'restart', 'rotate', 'scale', 'seed', 'shape-rendering', 'show', 'specularconstant',
        'specularexponent', 'spreadmethod', 'stddeviation', 'stitchtiles', 'stop-color',
        'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap',
        'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width',
        'surfacescale', 'targetx', 'targety', 'transform', 'text-anchor', 'text-decoration',
        'text-rendering', 'textlength', 'to', 'u1', 'u2', 'unicode', 'values', 'viewbox',
        'visibility', 'vert-adv-y', 'version', 'vert-origin-x', 'vert-origin-y', 'word-spacing',
        'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2',
        'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan',
    );
    /* Block elements which could be empty but cannot be returned in short form (<tag />) */
    static $block_elements = array('div', 'p', 'pre', 'blockquote', 'a', 'font', 'center',
        'table', 'ul', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ol', 'dl', 'strong',
        'i', 'b', 'u', 'span',
    /* Elements which could be empty and be returned in short form (<tag />) */
    static $void_elements = array('area', 'base', 'br', 'col', 'command', 'embed', 'hr',
        'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr',
    );
    /* State for linked objects in HTML */
@@ -134,11 +174,16 @@
    /* Ignore these HTML tags but process their content */
    private $_ignore_elements = array();
    /* Block elements which could be empty but cannot be returned in short form (<tag />) */
    private $_block_elements = array();
    /* Elements which could be empty and be returned in short form (<tag />) */
    private $_void_elements = array();
    /* Allowed HTML attributes */
    private $_html_attribs = array();
    /* Max nesting level */
    private $max_nesting_level;
    private $is_xml = false;
    /**
@@ -146,12 +191,12 @@
     */
    public function __construct($p = array())
    {
        $this->_html_elements   = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements) ;
        $this->_html_elements   = array_flip((array)$p['html_elements']) + array_flip(self::$html_elements);
        $this->_html_attribs    = array_flip((array)$p['html_attribs']) + array_flip(self::$html_attribs);
        $this->_ignore_elements = array_flip((array)$p['ignore_elements']) + array_flip(self::$ignore_elements);
        $this->_block_elements  = array_flip((array)$p['block_elements']) + array_flip(self::$block_elements);
        $this->_void_elements   = array_flip((array)$p['void_elements']) + array_flip(self::$void_elements);
        unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['block_elements']);
        unset($p['html_elements'], $p['html_attribs'], $p['ignore_elements'], $p['void_elements']);
        $this->config = $p + array('show_washed' => true, 'allow_remote' => false, 'cid_map' => array());
    }
@@ -169,7 +214,7 @@
     */
    private function wash_style($style)
    {
        $s = '';
        $result = array();
        foreach (explode(';', $style) as $declaration) {
            if (preg_match('/^\s*([a-z\-]+)\s*:\s*(.*)\s*$/i', $declaration, $match)) {
@@ -177,54 +222,34 @@
                $str   = $match[2];
                $value = '';
                while (sizeof($str) > 0 &&
                    preg_match('/^(url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)'./*1,2*/
                        '|rgb\(\s*[0-9]+\s*,\s*[0-9]+\s*,\s*[0-9]+\s*\)'.
                        '|-?[0-9.]+\s*(em|ex|px|cm|mm|in|pt|pc|deg|rad|grad|ms|s|hz|khz|%)?'.
                        '|#[0-9a-f]{3,6}'.
                        '|[a-z0-9", -]+'.
                        ')\s*/i', $str, $match)
                ) {
                    if ($match[2]) {
                        if (($src = $this->config['cid_map'][$match[2]])
                            || ($src = $this->config['cid_map'][$this->config['base_url'].$match[2]])
                        ) {
                            $value .= ' url('.htmlspecialchars($src, ENT_QUOTES) . ')';
                        }
                        else if (preg_match('!^(https?:)?//[a-z0-9/._+-]+$!i', $match[2], $url)) {
                            if ($this->config['allow_remote']) {
                                $value .= ' url('.htmlspecialchars($url[0], ENT_QUOTES).')';
                foreach ($this->explode_style($str) as $val) {
                    if (preg_match('/^url\(/i', $val)) {
                        if (preg_match('/^url\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)/iu', $val, $match)) {
                            if ($url = $this->wash_uri($match[1])) {
                                $value .= ' url(' . htmlspecialchars($url, ENT_QUOTES) . ')';
                            }
                            else {
                                $this->extlinks = true;
                            }
                        }
                        else if (preg_match('/^data:.+/i', $match[2])) { // RFC2397
                            $value .= ' url('.htmlspecialchars($match[2], ENT_QUOTES).')';
                        }
                    }
                    else {
                    else if (!preg_match('/^(behavior|expression)/i', $val)) {
                        // whitelist ?
                        $value .= ' ' . $match[0];
                        $value .= ' ' . $val;
                        // #1488535: Fix size units, so width:800 would be changed to width:800px
                        if (preg_match('/(left|right|top|bottom|width|height)/i', $cssid)
                            && preg_match('/^[0-9]+$/', $match[0])
                        if (preg_match('/^(left|right|top|bottom|width|height)/i', $cssid)
                            && preg_match('/^[0-9]+$/', $val)
                        ) {
                            $value .= 'px';
                        }
                    }
                    $str = substr($str, strlen($match[0]));
                }
                if (isset($value[0])) {
                    $s .= ($s?' ':'') . $cssid . ':' . $value . ';';
                    $result[] = $cssid . ':' . $value;
                }
            }
        }
        return $s;
        return implode('; ', $result);
    }
    /**
@@ -232,86 +257,205 @@
     */
    private function wash_attribs($node)
    {
        $t = '';
        $washed = '';
        $result = '';
        $washed = array();
        foreach ($node->attributes as $key => $plop) {
            $key   = strtolower($key);
            $value = $node->getAttribute($key);
        foreach ($node->attributes as $name => $attr) {
            $key   = strtolower($name);
            $value = $attr->nodeValue;
            if (isset($this->_html_attribs[$key]) ||
                ($key == 'href' && ($value = trim($value))
                    && !preg_match('!^(javascript|vbscript|data:text)!i', $value)
                    && preg_match('!^([a-z][a-z0-9.+-]+:|//|#).+!i', $value))
            ) {
                $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
            if ($key == 'style' && ($style = $this->wash_style($value))) {
                // replace double quotes to prevent syntax error and XSS issues (#1490227)
                $result .= ' style="' . str_replace('"', '&quot;', $style) . '"';
            }
            else if ($key == 'style' && ($style = $this->wash_style($value))) {
                $quot = strpos($style, '"') !== false ? "'" : '"';
                $t .= ' style=' . $quot . $style . $quot;
            }
            else if ($key == 'background' || ($key == 'src' && strtolower($node->tagName) == 'img')) { //check tagName anyway
                if (($src = $this->config['cid_map'][$value])
                    || ($src = $this->config['cid_map'][$this->config['base_url'].$value])
                ) {
                    $t .= ' ' . $key . '="' . htmlspecialchars($src, ENT_QUOTES) . '"';
                }
                else if (preg_match('/^(http|https|ftp):.+/i', $value)) {
                    if ($this->config['allow_remote']) {
                        $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
            else if (isset($this->_html_attribs[$key])) {
                $value = trim($value);
                $out   = null;
                // in SVG to/from attribs may contain anything, including URIs
                if ($key == 'to' || $key == 'from') {
                    $key = strtolower($node->getAttribute('attributeName'));
                    if ($key && !isset($this->_html_attribs[$key])) {
                        $key = null;
                    }
                    else {
                        $this->extlinks = true;
                        if ($this->config['blocked_src']) {
                            $t .= ' ' . $key . '="' . htmlspecialchars($this->config['blocked_src'], ENT_QUOTES) . '"';
                }
                if ($this->is_image_attribute($node->tagName, $key)) {
                    $out = $this->wash_uri($value, true);
                }
                else if ($this->is_link_attribute($node->tagName, $key)) {
                    if (!preg_match('!^(javascript|vbscript|data:text)!i', $value)
                        && preg_match('!^([a-z][a-z0-9.+-]+:|//|#).+!i', $value)
                    ) {
                        $out = $value;
                    }
                }
                else if ($this->is_funciri_attribute($node->tagName, $key)) {
                    if (preg_match('/^[a-z:]*url\(/i', $val)) {
                        if (preg_match('/^([a-z:]*url)\(\s*[\'"]?([^\'"\)]*)[\'"]?\s*\)/iu', $value, $match)) {
                            if ($url = $this->wash_uri($match[2])) {
                                $result .= ' ' . $attr->nodeName . '="' . $match[1] . '(' . htmlspecialchars($url, ENT_QUOTES) . ')'
                                     . substr($val, strlen($match[0])) . '"';
                                continue;
                            }
                        }
                        else {
                            $out = $value;
                        }
                    }
                    else {
                        $out = $value;
                    }
                }
                else if (preg_match('/^data:.+/i', $value)) { // RFC2397
                    $t .= ' ' . $key . '="' . htmlspecialchars($value, ENT_QUOTES) . '"';
                else if ($key) {
                   $out = $value;
                }
                if ($out !== null && $out !== '') {
                    $result .= ' ' . $attr->nodeName . '="' . htmlspecialchars($out, ENT_QUOTES) . '"';
                }
                else if ($value) {
                    $washed[] = htmlspecialchars($attr->nodeName, ENT_QUOTES);
                }
            }
            else {
                $washed .= ($washed ? ' ' : '') . $key;
                $washed[] = htmlspecialchars($attr->nodeName, ENT_QUOTES);
            }
        }
        return $t . ($washed && $this->config['show_washed'] ? ' x-washed="'.$washed.'"' : '');
        if (!empty($washed) && $this->config['show_washed']) {
            $result .= ' x-washed="' . implode(' ', $washed) . '"';
        }
        return $result;
    }
    /**
     * Wash URI value
     */
    private function wash_uri($uri, $blocked_source = false)
    {
        if (($src = $this->config['cid_map'][$uri])
            || ($src = $this->config['cid_map'][$this->config['base_url'].$uri])
        ) {
            return $src;
        }
        // allow url(#id) used in SVG
        if ($uri[0] == '#') {
            return $uri;
        }
        if (preg_match('/^(http|https|ftp):.+/i', $uri)) {
            if ($this->config['allow_remote']) {
                return $uri;
            }
            $this->extlinks = true;
            if ($blocked_source && $this->config['blocked_src']) {
                return $this->config['blocked_src'];
            }
        }
        else if (preg_match('/^data:image.+/i', $uri)) { // RFC2397
            return $uri;
        }
    }
    /**
     * Check it the tag/attribute may contain an URI
     */
    private function is_link_attribute($tag, $attr)
    {
        return ($tag == 'a' || $tag == 'area') && $attr == 'href';
    }
    /**
     * Check it the tag/attribute may contain an image URI
     */
    private function is_image_attribute($tag, $attr)
    {
        return $attr == 'background'
            || $attr == 'color-profile' // SVG
            || ($attr == 'poster' && $tag == 'video')
            || ($attr == 'src' && preg_match('/^(img|source)$/i', $tag))
            || ($tag == 'image' && $attr == 'href'); // SVG
    }
    /**
     * Check it the tag/attribute may contain a FUNCIRI value
     */
    private function is_funciri_attribute($tag, $attr)
    {
        return in_array($attr, array('fill', 'filter', 'stroke', 'marker-start',
            'marker-end', 'marker-mid', 'clip-path', 'mask', 'cursor'));
    }
    /**
     * The main loop that recurse on a node tree.
     * It output only allowed tags with allowed attributes
     * and allowed inline styles
     * It output only allowed tags with allowed attributes and allowed inline styles
     *
     * @param DOMNode $node  HTML element
     * @param int     $level Recurrence level (safe initial value found empirically)
     */
    private function dumpHtml($node)
    private function dumpHtml($node, $level = 20)
    {
        if (!$node->hasChildNodes()) {
            return '';
        }
        $level++;
        if ($this->max_nesting_level > 0 && $level == $this->max_nesting_level - 1) {
            // log error message once
            if (!$this->max_nesting_level_error) {
                $this->max_nesting_level_error = true;
                rcube::raise_error(array('code' => 500, 'type' => 'php',
                    'line' => __LINE__, 'file' => __FILE__,
                    'message' => "Maximum nesting level exceeded (xdebug.max_nesting_level={$this->max_nesting_level})"),
                    true, false);
            }
            return '<!-- ignored -->';
        }
        $node = $node->firstChild;
        $dump = '';
        do {
            switch($node->nodeType) {
            switch ($node->nodeType) {
            case XML_ELEMENT_NODE: //Check element
                $tagName = strtolower($node->tagName);
                if ($callback = $this->handlers[$tagName]) {
                    $dump .= call_user_func($callback, $tagName,
                        $this->wash_attribs($node), $this->dumpHtml($node), $this);
                        $this->wash_attribs($node), $this->dumpHtml($node, $level), $this);
                }
                else if (isset($this->_html_elements[$tagName])) {
                    $content = $this->dumpHtml($node);
                    $dump .= '<' . $tagName . $this->wash_attribs($node) .
                        ($content != '' || isset($this->_block_elements[$tagName]) ? ">$content</$tagName>" : ' />');
                    $content = $this->dumpHtml($node, $level);
                    $dump .= '<' . $node->tagName;
                    if ($tagName == 'svg') {
                        $xpath = new DOMXPath($node->ownerDocument);
                        foreach ($xpath->query('namespace::*') as $ns) {
                            if ($ns->nodeName != 'xmlns:xml') {
                                $dump .= ' ' . $ns->nodeName . '="' . $ns->nodeValue . '"';
                            }
                        }
                    }
                    $dump .= $this->wash_attribs($node);
                    if ($content === '' && ($this->is_xml || isset($this->_void_elements[$tagName]))) {
                        $dump .= ' />';
                    }
                    else {
                        $dump .= '>' . $content . '</' . $node->tagName . '>';
                    }
                }
                else if (isset($this->_ignore_elements[$tagName])) {
                    $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' not allowed -->';
                    $dump .= '<!-- ' . htmlspecialchars($node->tagName, ENT_QUOTES) . ' not allowed -->';
                }
                else {
                    $dump .= '<!-- ' . htmlspecialchars($tagName, ENT_QUOTES) . ' ignored -->';
                    $dump .= $this->dumpHtml($node); // ignore tags not its content
                    $dump .= '<!-- ' . htmlspecialchars($node->tagName, ENT_QUOTES) . ' ignored -->';
                    $dump .= $this->dumpHtml($node, $level); // ignore tags not its content
                }
                break;
@@ -324,16 +468,11 @@
                break;
            case XML_HTML_DOCUMENT_NODE:
                $dump .= $this->dumpHtml($node);
                $dump .= $this->dumpHtml($node, $level);
                break;
            case XML_DOCUMENT_TYPE_NODE:
                break;
            default:
                $dump . '<!-- node type ' . $node->nodeType . ' -->';
            }
        } while($node = $node->nextSibling);
        }
        while($node = $node->nextSibling);
        return $dump;
    }
@@ -358,7 +497,23 @@
            $this->config['base_url'] = '';
        }
        @$node->loadHTML($html);
        // Detect max nesting level (for dumpHTML) (#1489110)
        $this->max_nesting_level = (int) @ini_get('xdebug.max_nesting_level');
        // SVG need to be parsed as XML
        $this->is_xml = stripos($html, '<html') === false && stripos($html, '<svg') !== false;
        $method       = $this->is_xml ? 'loadXML' : 'loadHTML';
        $options      = 0;
        // Use optimizations if supported
        if (PHP_VERSION_ID >= 50400) {
            $options = LIBXML_PARSEHUGE | LIBXML_COMPACT | LIBXML_NONET;
            @$node->{$method}($html, $options);
        }
        else {
            @$node->{$method}($html);
        }
        return $this->dumpHtml($node);
    }
@@ -375,6 +530,8 @@
     */
    private function cleanup($html)
    {
        $html = trim($html);
        // special replacements (not properly handled by washtml class)
        $html_search = array(
            '/(<\/nobr>)(\s+)(<nobr>)/i',       // space(s) between <NOBR>
@@ -389,7 +546,29 @@
            '',
            '<html>',
        );
        $html = preg_replace($html_search, $html_replace, trim($html));
        // Replace all of those weird MS Word quotes and other high characters
        $badwordchars = array(
            "\xe2\x80\x98", // left single quote
            "\xe2\x80\x99", // right single quote
            "\xe2\x80\x9c", // left double quote
            "\xe2\x80\x9d", // right double quote
            "\xe2\x80\x94", // em dash
            "\xe2\x80\xa6"  // elipses
        );
        $fixedwordchars = array(
            "'",
            "'",
            '"',
            '"',
            '&mdash;',
            '...'
        );
        $html = str_replace($badwordchars, $fixedwordchars, $html);
        // PCRE errors handling (#1486856), should we use something like for every preg_* use?
        if ($html === null && ($preg_error = preg_last_error()) != PREG_NO_ERROR) {
@@ -405,16 +584,20 @@
            rcube::raise_error(array('code' => 620, 'type' => 'php',
                'line' => __LINE__, 'file' => __FILE__,
                'message' => $errstr), true, false);
            return '';
        }
        // fix (unknown/malformed) HTML tags before "wash"
        $html = preg_replace_callback('/(<[\/]*)([^\s>]+)/', array($this, 'html_tag_callback'), $html);
        $html = preg_replace_callback('/(<(?!\!)[\/]*)([^\s>]+)([^>]*)/', array($this, 'html_tag_callback'), $html);
        // Remove invalid HTML comments (#1487759)
        // Don't remove valid conditional comments
        // Don't remove MSOutlook (<!-->) conditional comments (#1489004)
        $html = preg_replace('/<!--[^->\[\n]+>/', '', $html);
        $html = preg_replace('/<!--[^-<>\[\n]+>/', '', $html);
        // fix broken nested lists
        self::fix_broken_lists($html);
        // turn relative into absolute urls
        $html = self::resolve_base($html);
@@ -430,10 +613,15 @@
        $tagname = $matches[2];
        $tagname = preg_replace(array(
            '/:.*$/',               // Microsoft's Smart Tags <st1:xxxx>
            '/[^a-z0-9_\[\]\!-]/i', // forbidden characters
            '/[^a-z0-9_\[\]\!?-]/i', // forbidden characters
        ), '', $tagname);
        return $matches[1] . $tagname;
        // fix invalid closing tags - remove any attributes (#1489446)
        if ($matches[1] == '</') {
            $matches[3] = '';
        }
        return $matches[1] . $tagname . $matches[3];
    }
    /**
@@ -449,5 +637,122 @@
        return $body;
    }
}
    /**
     * Fix broken nested lists, they are not handled properly by DOMDocument (#1488768)
     */
    public static function fix_broken_lists(&$html)
    {
        // do two rounds, one for <ol>, one for <ul>
        foreach (array('ol', 'ul') as $tag) {
            $pos = 0;
            while (($pos = stripos($html, '<' . $tag, $pos)) !== false) {
                $pos++;
                // make sure this is an ol/ul tag
                if (!in_array($html[$pos+2], array(' ', '>'))) {
                    continue;
                }
                $p      = $pos;
                $in_li  = false;
                $li_pos = 0;
                while (($p = strpos($html, '<', $p)) !== false) {
                    $tt = strtolower(substr($html, $p, 4));
                    // li open tag
                    if ($tt == '<li>' || $tt == '<li ') {
                        $in_li = true;
                        $p += 4;
                    }
                    // li close tag
                    else if ($tt == '</li' && in_array($html[$p+4], array(' ', '>'))) {
                        $li_pos = $p;
                        $p += 4;
                        $in_li = false;
                    }
                    // ul/ol closing tag
                    else if ($tt == '</' . $tag && in_array($html[$p+4], array(' ', '>'))) {
                        break;
                    }
                    // nested ol/ul element out of li
                    else if (!$in_li && $li_pos && ($tt == '<ol>' || $tt == '<ol ' || $tt == '<ul>' || $tt == '<ul ')) {
                        // find closing tag of this ul/ol element
                        $element = substr($tt, 1, 2);
                        $cpos    = $p;
                        do {
                            $tpos = stripos($html, '<' . $element, $cpos+1);
                            $cpos = stripos($html, '</' . $element, $cpos+1);
                        }
                        while ($tpos !== false && $cpos !== false && $cpos > $tpos);
                        // not found, this is invalid HTML, skip it
                        if ($cpos === false) {
                            break;
                        }
                        // get element content
                        $end     = strpos($html, '>', $cpos);
                        $len     = $end - $p + 1;
                        $element = substr($html, $p, $len);
                        // move element to the end of the last li
                        $html    = substr_replace($html, '', $p, $len);
                        $html    = substr_replace($html, $element, $li_pos, 0);
                        $p = $end;
                    }
                    else {
                        $p++;
                    }
                }
            }
        }
    }
    /**
     * Explode css style value
     */
    protected function explode_style($style)
    {
        $style = trim($style);
        // first remove comments
        $pos = 0;
        while (($pos = strpos($style, '/*', $pos)) !== false) {
            $end = strpos($style, '*/', $pos+2);
            if ($end === false) {
                $style = substr($style, 0, $pos);
            }
            else {
                $style = substr_replace($style, '', $pos, $end - $pos + 2);
            }
        }
        $strlen = strlen($style);
        $result = array();
        // explode value
        for ($p=$i=0; $i < $strlen; $i++) {
            if (($style[$i] == "\"" || $style[$i] == "'") && $style[$i-1] != "\\") {
                if ($q == $style[$i]) {
                    $q = false;
                }
                else if (!$q) {
                    $q = $style[$i];
                }
            }
            if (!$q && $style[$i] == ' ' && !preg_match('/[,\(]/', $style[$i-1])) {
                $result[] = substr($style, $p, $i - $p);
                $p = $i + 1;
            }
        }
        $result[] = (string) substr($style, $p);
        return $result;
    }
}