<?php
$string = '<span class="test">Lorem <i>ipsum</i> <b id="the-b">dolor</b> sit amet!</span>';
print HtmlTruncate::truncate($string, 10) . PHP_EOL;
 
class HtmlTruncate
{
    const READ_STRING = 1;
    const READ_OPEN_TAG = 2;
    const READ_CLOSE_TAG = 3;
 
    public static function truncate($string, $length)
    {
 
        $left = $length;
        $state = self::READ_STRING;
        $trunc = '';
        $tags = 0;
 
        for ($i = 0; $i < strlen($string); $i++) {
            $c = $string[$i];
 
            switch ($state) {
                case self::READ_STRING:
                    if ($c == '<') {
                        if ($string[$i+1] == '/') {
                            $state = self::READ_CLOSE_TAG;
                        } else {
                            $state = self::READ_OPEN_TAG;
                            $tags += $left ? 0 : 1;
                        }
                        $i--;
                        continue;
                    } else {
                        if ($left) {
                            $trunc .= $c;
                        } else {
                            print "skip: " . $c . PHP_EOL;
                        }
                        $left = max($left-1, 0);
                    }
                    break;
 
                case self::READ_OPEN_TAG:
                    if ($left) {
                        $trunc .= $c;
                    }
 
                    if ($c == '>') {
                        $state = self::READ_STRING;
                    }
                    break;
 
                case self::READ_CLOSE_TAG:
                    if ($left || !$tags) {
                        $trunc .= $c;
                    }
 
                    if ($c == '>') {
                        $tags = max($tags-1, 0);
                        $state = self::READ_STRING;
                    }
                    break;
            }
        }
 
        return $trunc;
    }
}