Вырезать часть html, не разрезая теги на php

Задача: Вам нужно разрезать html на блоки нужного размера. Например, при отправке сообщения в телеграм можно отправлять не больше чем по 4096 символов. Сложность ещё в парных тегах, например вы не можете разрезать часть тега <a ... > ... </a ... >

Варианты решения:

Вариант 1:

function html_cut($text, $max_length)
{
    $tags = array();
    $result = "";
    $is_open = false;
    $grab_open = false;
    $is_close = false;
    $in_double_quotes = false;
    $in_single_quotes = false;
    $tag = "";
    $i = 0;
    $stripped = 0;
    $stripped_text = strip_tags($text);
    while ($i < strlen($text) && $stripped < strlen($stripped_text) && $stripped < $max_length) {
        $symbol = $text{$i};
        $result .= $symbol;
        switch ($symbol) {
            case '<':
                $is_open = true;
                $grab_open = true;
                break;
            case '"':
                if ($in_double_quotes)
                    $in_double_quotes = false; else $in_double_quotes = true;
                break;
            case "'":
                if ($in_single_quotes) $in_single_quotes = false; else $in_single_quotes = true;
                break;
            case '/':
                if ($is_open && !$in_double_quotes && !$in_single_quotes) {
                    $is_close = true;
                    $is_open = false;
                    $grab_open = false;
                }
                break;
            case ' ':
                if ($is_open) $grab_open = false; else $stripped++;
                break;
            case '>':
                if ($is_open) {
                    $is_open = false;
                    $grab_open = false;
                    array_push($tags, $tag);
                    $tag = "";
                } else if ($is_close) {
                    $is_close = false;
                    array_pop($tags);
                    $tag = "";
                }
                break;
            default:
                if ($grab_open || $is_close) $tag .= $symbol;
                if (!$is_open && !$is_close)
                    $stripped++;
        }
        $i++;
    }
    while ($tags) $result .= "</" . array_pop($tags) . ">";
    return $result;
}

Пример использования:

    $content = html_cut($content, 100);

Вариант 2:

В CakePHP существует метод Text::truncate() который делает обрезку HTML без нарушения структуры тегов:

function truncate($text, $length = 100, $ending = '...', $exact = true, $considerHtml = false)
{
    if (is_array($ending)) {
        extract($ending);
    }
    if ($considerHtml) {
        if (mb_strlen(preg_replace('/<.*?>/', '', $text)) <= $length) {
            return $text;
        }
        $totalLength = mb_strlen($ending);
        $openTags = array();
        $truncate = '';
        preg_match_all('/(<\/?([\w+]+)[^>]*>)?([^<>]*)/', $text, $tags, PREG_SET_ORDER);
        foreach ($tags as $tag) {
            if (!preg_match('/img|br|input|hr|area|base|basefont|col|frame|isindex|link|meta|param/s', $tag[2])) {
                if (preg_match('/<[\w]+[^>]*>/s', $tag[0])) {
                    array_unshift($openTags, $tag[2]);
                } else if (preg_match('/<\/([\w]+)[^>]*>/s', $tag[0], $closeTag)) {
                    $pos = array_search($closeTag[1], $openTags);
                    if ($pos !== false) {
                        array_splice($openTags, $pos, 1);
                    }
                }
            }
            $truncate .= $tag[1];
            $contentLength = mb_strlen(preg_replace('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i', ' ', $tag[3]));
            if ($contentLength + $totalLength > $length) {
                $left = $length - $totalLength;
                $entitiesLength = 0;
                if (preg_match_all('/&[0-9a-z]{2,8};|&#[0-9]{1,7};|&#x[0-9a-f]{1,6};/i', $tag[3], $entities, PREG_OFFSET_CAPTURE)) {
                    foreach ($entities[0] as $entity) {
                        if ($entity[1] + 1 - $entitiesLength <= $left) {
                            $left--;
                            $entitiesLength += mb_strlen($entity[0]);
                        } else {
                            break;
                        }
                    }
                }
                $truncate .= mb_substr($tag[3], 0, $left + $entitiesLength);
                break;
            } else {
                $truncate .= $tag[3];
                $totalLength += $contentLength;
            }
            if ($totalLength >= $length) {
                break;
            }
        }
    } else {
        if (mb_strlen($text) <= $length) {
            return $text;
        } else {
            $truncate = mb_substr($text, 0, $length - strlen($ending));
        }
    }
    if (!$exact) {
        $spacepos = mb_strrpos($truncate, ' ');
        if (isset($spacepos)) {
            if ($considerHtml) {
                $bits = mb_substr($truncate, $spacepos);
                preg_match_all('/<\/([az]+)>/', $bits, $droppedTags, PREG_SET_ORDER);
                if (!empty($droppedTags)) {
                    foreach ($droppedTags as $closingTag) {
                        if (!in_array($closingTag[1], $openTags)) {
                            array_unshift($openTags, $closingTag[1]);
                        }
                    }
                }
            }
            $truncate = mb_substr($truncate, 0, $spacepos);
        }
    }
    $truncate .= $ending;
    if ($considerHtml) {
        foreach ($openTags as $tag) {
            $truncate .= '</' . $tag . '>';
        }
    }
    return $truncate;
}

Используйте класс DOMDocument PHP для нормализации фрагмента HTML:

$dom = new DOMDocument();
$dom->loadHTML('<div><p>Hello World');
$xpath = new DOMXPath($dom);
$body = $xpath->query('/html/body');
echo($dom->saveXml($body->item(0)));

Вариант 3 с поддержкой UTF-8:

/** * Limit string without break html tags. * Supports UTF8 * *
 * @param string $value * @param int $limit Default 100
 */
function str_limit_html($value, $limit = 100)
{
    if (mb_strwidth($value, 'UTF-8') <= $limit) {
        return $value;
    }
    // Strip text with HTML tags, sum html len tags too.
    // Is there another way to do it?
    do {
        $len = mb_strwidth($value, 'UTF-8');
        $len_stripped = mb_strwidth(strip_tags($value), 'UTF-8');
        $len_tags = $len - $len_stripped;
        $value = mb_strimwidth($value, 0, $limit + $len_tags, '', 'UTF-8');
    } while ($len_stripped > $limit);
    // Load as HTML ignoring errors $dom = new DOMDocument();
    $doc=new DOMDocument('1.0','utf-8');
    @$dom->loadHTML('<?xml encoding="utf-8" ?>' . $value, LIBXML_HTML_NODEFDTD);
    // Fix the html errors
    $value = $dom->saveHtml($dom->getElementsByTagName('body')->item(0));
    // Remove body tag
    $value = mb_strimwidth($value, 6, mb_strwidth($value, 'UTF-8') - 13, '', 'UTF-8');
    // <body> and </body>
    // Remove empty tags
    return preg_replace('/<(\w+)\b(?:\s+[\w\-.:]+(?:\s*=\s*(?:"[^"]*"|"[^"]*"|[\w\-.:]+))?)*\s*\/?>\s*<\/\1\s*>/', '', $value);
}

Рекомендуется использовать html_entity_decode в начале функции, поэтому сохраните символы UTF-8:

$value = html_entity_decode($value);

Независимо от 100 вопросов подсчета, которые вы указываете в начале, вы указываете в вызове следующее:

  • выводить количество символов strip_tags (количество символов в фактическом отображаемом тексте HTML)
  • сохранить форматирование HTML
  • закрыть любой незавершенный тег HTML

Я анализирую каждый персонаж, подсчитывающий, когда я иду. Я удостоверяюсь, что НЕ подсчитывать любые символы в любом теге HTML. Я также проверяю в конце, чтобы убедиться, что я не посередине, когда останавливаюсь. Как только я остановлюсь, я вернусь к первому доступному пространству или как к точке остановки.

$position = 0;
$length = strlen($content) - 1; // process the content putting each 100 character section into an array
while ($position < $length) {
    $next_position = get_position($content, $position, 100);
    $data[] = substr($content, $position, $next_position);
    $position = $next_position;
}
// show the array
print_r($data);

function get_position($content, $position, $chars = 100)
{
    $count = 0; // count to 100 characters skipping over all of the HTML
    while ($count <> $chars) {
        $char = substr($content, $position, 1);
        if ($char == '<') {
            do {
                $position++;
                $char = substr($content, $position, 1);
            } while ($char !== '>');
            $position++;
            $char = substr($content, $position, 1);
        }
        $count++;
        $position++;
    }
    echo $count . "\n";
    // find out where there is a logical break before 100 characters
    $data = substr($content, 0, $position);
    $space = strrpos($data, " ");
    $tag = strrpos($data, ">");
    // return the position of the logical break
    if ($space > $tag) {
        return $space;
    } else {
        return $tag;
    }
}

Это также будет учитывать коды возврата и т.д. Учитывая, что они занимают место, я их не удалял.

Вот функция, которую я использую в одном из моих проектов. Он основан на DOMDocument, работает с HTML5 и примерно в 2 раза быстрее, чем другие решения, которые я пробовал (по крайней мере на моей машине, 0,22 мс против 0,43 мс, используя html_cut($text, $max_length) из верхнего ответа на 500 текстовых сообщений, строка узловых символов с лимитом 400).

function cut_html($html, $limit)
{
    $dom = new DOMDocument();
    $dom->loadHTML(mb_convert_encoding("<div>{$html}</div>", "HTML-ENTITIES", "UTF-8"), LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
    cut_html_recursive($dom->documentElement, $limit);
    return substr($dom->saveHTML($dom->documentElement), 5, -6);
}

function cut_html_recursive($element, $limit)
{
    if ($limit > 0) {
        if ($element->nodeType == 3) {
            $limit -= strlen($element->nodeValue);
            if ($limit < 0) {
                $element->nodeValue = substr($element->nodeValue, 0, strlen($element->nodeValue) + $limit);
            }
        } else {
            for ($i = 0; $i < $element->childNodes->length; $i++) {
                if ($limit > 0) {
                    $limit = cut_html_recursive($element->childNodes->item($i), $limit);
                } else {
                    $element->removeChild($element->childNodes->item($i));
                    $i--;
                }
            }
        }
    }
    return $limit;
}

Вот моя попытка на резаке. Возможно, вы, ребята, можете поймать некоторые ошибки. Проблема, которую я нашел с другими синтаксическими анализаторами, заключается в том, что они не закрывают теги должным образом, и они разрезают посреди слова (бла)

function cutHTML($string, $length, $patternsReplace = false)
{
    $i = 0;
    $count = 0;
    $isParagraphCut = false;
    $htmlOpen = false;
    $openTag = false;
    $tagsStack = array();
    while ($i < strlen($string)) {
        $char = substr($string, $i, 1);
        if ($count >= $length) {
            $isParagraphCut = true;
            break;
        }
        if ($htmlOpen) {
            if ($char === ">") {
                $htmlOpen = false;
            }
        } else {
            if ($char === "<") {
                $j = $i;
                $char = substr($string, $j, 1);
                while ($j < strlen($string)) {
                    if ($char === '/') {
                        $i++;
                        break;
                    } elseif ($char === ' ') {
                        $tagsStack[] = substr($string, $i, $j);
                    }
                    $j++;
                }
                $htmlOpen = true;
            }
        }
        if (!$htmlOpen && $char != ">") {
            $count++;
        }
        $i++;
    }
    if ($isParagraphCut) {
        $j = $i;
        while ($j > 0) {
            $char = substr($string, $j, 1);
            if ($char === " " || $char === ";" || $char === "." || $char === "," || $char === "<" || $char === "(" || $char === "[") {
                break;
            } else if ($char === ">") {
                $j++;
                break;
            }
            $j--;
        }
        $string = substr($string, 0, $j);
        foreach ($tagsStack as $tag) {
            $tag = strtolower($tag);
            if (!in_array($tag,["img","br","hr"])) {
                $string .= "</$tag>";
            }
        }
        $string .= "...";
    }
    if ($patternsReplace) {
        foreach ($patternsReplace as $value) {
            if (isset($value['pattern']) && isset($value["replace"])) {
                $string = preg_replace($value["pattern"], $value["replace"], $string);
            }
        }
    }
    return $string;
}

.