public function truncatePreservingTags($str, $textMaxLength, $postString = '...', $encoding = 'UTF-8')
    {
        // complete string length with tags
        $htmlLength = mb_strlen($str, $encoding);
        // cursor position
        $htmlPos = 0;
        // extracted text length without tags
        $textLength = 0;
        // tags that need to closed
        $closeTag = array();

        if ($htmlLength <= $textMaxLength) {
            return $str;
        }

        $inEntity = false;

        // loop through str, start at cursor position
        for ($i = $htmlPos; $i < $htmlLength; ++$i) {
            // extract multibyte encoded char on cursor position
            $char = mb_substr($str, $i, 1, $encoding);

            if ($char === '&') {
                if (!$inEntity) {
                    $inEntity = true;
                }
            } elseif ($char === ';') {
                if ($inEntity) {
                    $inEntity = false;
                }
            } // check on an angle bracket assuming text following is a tag
            elseif ($char === '<') {
                // sub cursor position inside the tag (after the first angle bracket)
                $tagPos = $i + 1;
                // remember first position inside the tag
                $tagFirst = $tagPos;
                // size of tag (including angle brackets <1234>)
                $tagSize = 1;
                // tag name
                $tagName = '';

                // tag is valid ( <tag> OR </tag> )
                $isTag = true;
                // tag is a closing tag </tag>
                $isClosingTag = false;

                // loop through text inside the tag until it is closed
                for ($j = $tagPos; $j < $htmlLength; ++$j) {
                    // extract multibyte encoded char on sub cursor position
                    $charTag = mb_substr($str, $j, 1, $encoding);

                    // another opening angle bracket = first angle bracket serves as "smaller as"
                    // not a valid tag, set mark and break
                    if ($charTag === '<') {
                        $isTag = false;
                        break;
                    }
                    // seems to be an '<>' entity
                    // not a valid tag, set mark and break
                    elseif ($tagFirst === $j
                        && $charTag === '>'
                    ) {
                        $isTag = false;
                        break;
                    } // closing tag, set mark
                    elseif ($tagFirst === $j
                        && $charTag === '/'
                    ) {
                        $isClosingTag = true;
                    } // tag has ended, break
                    elseif ($charTag === '>') {
                        break;
                    } // remember char as tag name
                    else {
                        $tagName .= $charTag;
                    }

                    ++$tagSize;
                }

                // valid tag
                if (!empty($isTag)) {
                    // closing tag </tag>
                    if (!empty($isClosingTag)) {
                        // remove it from closing array
                        array_pop($closeTag);
                    } // not a closing tag
                    else {
                        // remember to close it
                        $closeTag[] = $tagName;
                    }

                    // set cursor position, continue with next char after the tag
                    $i = $i + $tagSize;
                } // not a valid tag
                else {
                    // set assumed tag size as additional textLength, because it is text
                    $textLength = $textLength + $tagSize;
                    // set cursor position and reset the last < or > char to start a new check
                    $i = $i + $tagSize - 1;
                }
            } // normal char, increase textLength
            else {
                ++$textLength;
            }

            // if maximum length reached break
            if (!$inEntity && $textLength >= $textMaxLength) {
                break;
            }
        }

        // set cursor to new position
        $htmlPos = $i + 1;

        // extract preview text from 0 to current cursor position
        $ret = mb_substr($str, 0, $htmlPos, $encoding);

        // if necessary set placeholder string (before tags get closed)
        if ($textLength >= $textMaxLength) {
            $ret .= $postString;
        }

        // if we are between tags, close them correctly
        if (count($closeTag)) {
            foreach (array_reverse($closeTag) as $tag) {
                $tokens = explode(' ', $tag);
                $ret .= '</'.$tokens[0].'>';
            }
        }

        return $ret;
    }