Обрезать текст, содержащий HTML, игнорируя теги
Я хочу обрезать некоторый текст (загруженный из базы данных или текстового файла), но он содержит HTML, поэтому в результате включаются теги и возвращается меньше текста. Это может привести к тому, что теги не будут закрыты или частично закрыты (поэтому Tidy может не работать должным образом, а контента по-прежнему меньше). Как я могу усечь, основываясь на тексте (и, возможно, остановиться, когда вы доберетесь до таблицы, так как это может вызвать более сложные проблемы).
substr("Hello, my <strong>name</strong> is <em>Sam</em>. I´m a web developer.",0,26)."..."
В результате:
Hello, my <strong>name</st...
Что бы я хотел, это:
Hello, my <strong>name</strong> is <em>Sam</em>. I´m...
Как я могу это сделать?
Хотя мой вопрос заключается в том, как сделать это в PHP, было бы хорошо узнать, как это сделать в C#... либо все должно быть в порядке, так как я думаю, что я смог бы перенести метод поверх (если он не является встроенным в метод).
Также обратите внимание, что я включил HTML-сущность ´
- который должен рассматриваться как один символ (а не 7 символов, как в этом примере).
strip_tags
это запасной вариант, но я бы потерял форматирование и ссылки, и все равно возникла бы проблема с сущностями HTML.
13 ответов
Предполагая, что вы используете действительный XHTML, просто проанализировать HTML и убедиться, что теги обрабатываются правильно. Вам просто нужно отследить, какие теги были открыты до сих пор, и обязательно закрыть их снова "на выходе".
<?php
header('Content-type: text/plain; charset=utf-8');
function printTruncated($maxLength, $html, $isUtf8=true)
{
$printedLength = 0;
$position = 0;
$tags = array();
// For UTF-8, we need to count multibyte sequences as one character.
$re = $isUtf8
? '{</?([a-z]+)[^>]*>|&#?[a-zA-Z0-9]+;|[\x80-\xFF][\x80-\xBF]*}'
: '{</?([a-z]+)[^>]*>|&#?[a-zA-Z0-9]+;}';
while ($printedLength < $maxLength && preg_match($re, $html, $match, PREG_OFFSET_CAPTURE, $position))
{
list($tag, $tagPosition) = $match[0];
// Print text leading up to the tag.
$str = substr($html, $position, $tagPosition - $position);
if ($printedLength + strlen($str) > $maxLength)
{
print(substr($str, 0, $maxLength - $printedLength));
$printedLength = $maxLength;
break;
}
print($str);
$printedLength += strlen($str);
if ($printedLength >= $maxLength) break;
if ($tag[0] == '&' || ord($tag) >= 0x80)
{
// Pass the entity or UTF-8 multibyte sequence through unchanged.
print($tag);
$printedLength++;
}
else
{
// Handle the tag.
$tagName = $match[1][0];
if ($tag[1] == '/')
{
// This is a closing tag.
$openingTag = array_pop($tags);
assert($openingTag == $tagName); // check that tags are properly nested.
print($tag);
}
else if ($tag[strlen($tag) - 2] == '/')
{
// Self-closing tag.
print($tag);
}
else
{
// Opening tag.
print($tag);
$tags[] = $tagName;
}
}
// Continue after the tag.
$position = $tagPosition + strlen($tag);
}
// Print any remaining text.
if ($printedLength < $maxLength && $position < strlen($html))
print(substr($html, $position, $maxLength - $printedLength));
// Close any open tags.
while (!empty($tags))
printf('</%s>', array_pop($tags));
}
printTruncated(10, '<b><Hello></b> <img src="world.png" alt="" /> world!'); print("\n");
printTruncated(10, '<table><tr><td>Heck, </td><td>throw</td></tr><tr><td>in a</td><td>table</td></tr></table>'); print("\n");
printTruncated(10, "<em><b>Hello</b>w\xC3\xB8rld!</em>"); print("\n");
Примечание по кодированию: в приведенном выше коде предполагается, что XHTML имеет кодировку UTF-8. ASCII-совместимые однобайтовые кодировки (такие как Latin-1) также поддерживаются, просто передайте false
в качестве третьего аргумента. Другие многобайтовые кодировки не поддерживаются, хотя вы можете взломать поддержку, используя mb_convert_encoding
конвертировать в UTF-8 перед вызовом функции, а затем конвертировать обратно в каждом print
заявление.
(Вы всегда должны использовать UTF-8, хотя.)
Редактировать: Обновлено для обработки персонажей и UTF-8. Исправлена ошибка, при которой функция выводила бы один символ слишком много, если бы этот символ был символьной сущностью.
Я написал функцию, которая обрезает HTML так, как вы предлагаете, но вместо того, чтобы распечатывать его, он помещает его в строковую переменную. также обрабатывает HTML-объекты.
/**
* function to truncate and then clean up end of the HTML,
* truncates by counting characters outside of HTML tags
*
* @author alex lockwood, alex dot lockwood at websightdesign
*
* @param string $str the string to truncate
* @param int $len the number of characters
* @param string $end the end string for truncation
* @return string $truncated_html
*
* **/
public static function truncateHTML($str, $len, $end = '…'){
//find all tags
$tagPattern = '/(<\/?)([\w]*)(\s*[^>]*)>?|&[\w#]+;/i'; //match html tags and entities
preg_match_all($tagPattern, $str, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER );
//WSDDebug::dump($matches); exit;
$i =0;
//loop through each found tag that is within the $len, add those characters to the len,
//also track open and closed tags
// $matches[$i][0] = the whole tag string --the only applicable field for html enitities
// IF its not matching an &htmlentity; the following apply
// $matches[$i][1] = the start of the tag either '<' or '</'
// $matches[$i][2] = the tag name
// $matches[$i][3] = the end of the tag
//$matces[$i][$j][0] = the string
//$matces[$i][$j][1] = the str offest
while($matches[$i][0][1] < $len && !empty($matches[$i])){
$len = $len + strlen($matches[$i][0][0]);
if(substr($matches[$i][0][0],0,1) == '&' )
$len = $len-1;
//if $matches[$i][2] is undefined then its an html entity, want to ignore those for tag counting
//ignore empty/singleton tags for tag counting
if(!empty($matches[$i][2][0]) && !in_array($matches[$i][2][0],array('br','img','hr', 'input', 'param', 'link'))){
//double check
if(substr($matches[$i][3][0],-1) !='/' && substr($matches[$i][1][0],-1) !='/')
$openTags[] = $matches[$i][2][0];
elseif(end($openTags) == $matches[$i][2][0]){
array_pop($openTags);
}else{
$warnings[] = "html has some tags mismatched in it: $str";
}
}
$i++;
}
$closeTags = '';
if (!empty($openTags)){
$openTags = array_reverse($openTags);
foreach ($openTags as $t){
$closeTagString .="</".$t . ">";
}
}
if(strlen($str)>$len){
// Finds the last space from the string new length
$lastWord = strpos($str, ' ', $len);
if ($lastWord) {
//truncate with new len last word
$str = substr($str, 0, $lastWord);
//finds last character
$last_character = (substr($str, -1, 1));
//add the end text
$truncated_html = ($last_character == '.' ? $str : ($last_character == ',' ? substr($str, 0, -1) : $str) . $end);
}
//restore any open tags
$truncated_html .= $closeTagString;
}else
$truncated_html = $str;
return $truncated_html;
}
Я использовал хорошую функцию, найденную на http://alanwhipple.com/2011/05/25/php-truncate-string-preserving-html-tags-words, по-видимому, взятую из CakePHP
100% точный, но довольно сложный подход:
- Итерация символов с использованием DOM
- Используйте методы DOM, чтобы удалить оставшиеся элементы
- Сериализация DOM
Простой подход грубой силы:
- Разбить строку на теги (не элементы) и фрагменты текста, используя
preg_split('/(<tag>)/')
с PREG_DELIM_CAPTURE. - Измерьте длину текста, который вы хотите (это будет каждый второй элемент из разделения, вы можете использовать
html_entity_decode()
чтобы помочь точно измерить) - Разрезать строку (обрезать
&[^\s;]+$
в конце избавиться от возможно расколотого лица) - Исправить это с помощью HTML Tidy
Вы также можете использовать Tidy:
function truncate_html($html, $max_length) {
return tidy_repair_string(substr($html, 0, $max_length),
array('wrap' => 0, 'show-body-only' => TRUE), 'utf8');
}
Ниже приведен простой анализатор конечного автомата, который успешно обрабатывает тестовый пример. Я терплю неудачу на вложенных тегах, хотя, поскольку он не отслеживает сами теги. Я также подавляю сущности в тегах HTML (например, в href
-атрибут <a>
-тег). Таким образом, это не может считаться 100% решением этой проблемы, но потому что это легко понять, это может стать основой для более продвинутой функции.
function substr_html($string, $length)
{
$count = 0;
/*
* $state = 0 - normal text
* $state = 1 - in HTML tag
* $state = 2 - in HTML entity
*/
$state = 0;
for ($i = 0; $i < strlen($string); $i++) {
$char = $string[$i];
if ($char == '<') {
$state = 1;
} else if ($char == '&') {
$state = 2;
$count++;
} else if ($char == ';') {
$state = 0;
} else if ($char == '>') {
$state = 0;
} else if ($state === 0) {
$count++;
}
if ($count === $length) {
return substr($string, 0, $i + 1);
}
}
return $string;
}
Платформа CakePHP имеет функцию truncate() с поддержкой HTML в TextHelper, которая работает для меня. Смотрите Core-Helpers / Text. Лицензия MIT.
Еще одно изменение в функции Søren Løvborg printTruncated, делающее ее совместимой с UTF-8 (Needs mbstring) и возвращающей строку, а не напечатанную. Я думаю, что это более полезно. И мой код не использует буферизацию, как вариант Bounce, только еще одну переменную.
UPD: для правильной работы с символами utf-8 в атрибутах тега вам нужна функция mb_preg_match, перечисленная ниже.
Огромное спасибо Сорену Левборгу за эту функцию, это очень хорошо.
/* Truncate HTML, close opened tags
*
* @param int, maxlength of the string
* @param string, html
* @return $html
*/
function htmlTruncate($maxLength, $html)
{
mb_internal_encoding("UTF-8");
$printedLength = 0;
$position = 0;
$tags = array();
$out = "";
while ($printedLength < $maxLength && mb_preg_match('{</?([a-z]+)[^>]*>|&#?[a-zA-Z0-9]+;}', $html, $match, PREG_OFFSET_CAPTURE, $position))
{
list($tag, $tagPosition) = $match[0];
// Print text leading up to the tag.
$str = mb_substr($html, $position, $tagPosition - $position);
if ($printedLength + mb_strlen($str) > $maxLength)
{
$out .= mb_substr($str, 0, $maxLength - $printedLength);
$printedLength = $maxLength;
break;
}
$out .= $str;
$printedLength += mb_strlen($str);
if ($tag[0] == '&')
{
// Handle the entity.
$out .= $tag;
$printedLength++;
}
else
{
// Handle the tag.
$tagName = $match[1][0];
if ($tag[1] == '/')
{
// This is a closing tag.
$openingTag = array_pop($tags);
assert($openingTag == $tagName); // check that tags are properly nested.
$out .= $tag;
}
else if ($tag[mb_strlen($tag) - 2] == '/')
{
// Self-closing tag.
$out .= $tag;
}
else
{
// Opening tag.
$out .= $tag;
$tags[] = $tagName;
}
}
// Continue after the tag.
$position = $tagPosition + mb_strlen($tag);
}
// Print any remaining text.
if ($printedLength < $maxLength && $position < mb_strlen($html))
$out .= mb_substr($html, $position, $maxLength - $printedLength);
// Close any open tags.
while (!empty($tags))
$out .= sprintf('</%s>', array_pop($tags));
return $out;
}
function mb_preg_match(
$ps_pattern,
$ps_subject,
&$pa_matches,
$pn_flags = 0,
$pn_offset = 0,
$ps_encoding = NULL
) {
// WARNING! - All this function does is to correct offsets, nothing else:
//(code is independent of PREG_PATTER_ORDER / PREG_SET_ORDER)
if (is_null($ps_encoding)) $ps_encoding = mb_internal_encoding();
$pn_offset = strlen(mb_substr($ps_subject, 0, $pn_offset, $ps_encoding));
$ret = preg_match($ps_pattern, $ps_subject, $pa_matches, $pn_flags, $pn_offset);
if ($ret && ($pn_flags & PREG_OFFSET_CAPTURE))
foreach($pa_matches as &$ha_match) {
$ha_match[1] = mb_strlen(substr($ps_subject, 0, $ha_match[1]), $ps_encoding);
}
return $ret;
}
Bounce добавил поддержку многобайтовых символов в решение Søren Løvborg - я добавил:
- поддержка непарных тегов HTML (например,
<hr>
,<br>
<col>
и т. д. не закрываются - в HTML "/" не требуется в конце этого (хотя это для XHTML), - настраиваемый индикатор усечения (по умолчанию
&hellips;
то есть…), - вернуть в виде строки без использования выходного буфера, и
- модульные тесты со 100% покрытием.
Все это в Пасти.
Используйте функцию truncateHTML()
от: https://github.com/jlgrall/truncateHTML
Пример: усечение после 9 символов, включая многоточие:
truncateHTML(9, "<p><b>A</b> red ball.</p>", ['wholeWord' => false]);
// => "<p><b>A</b> red ba…</p>"
Особенности: UTF-8, настраиваемый многоточие, включает / исключает длину многоточия, самозакрывающиеся метки, сворачивающиеся пробелы, невидимые элементы (<head>
, <script>
, <noscript>
, <style>
, <!-- comments -->
), HTML $entities;
усечение последнего целого слова (с возможностью по-прежнему усекать очень длинные слова), PHP 5.6 и 7.0+, модульные тесты 240+, возвращает строку (не использует выходной буфер) и хорошо прокомментированный код.
Я написал эту функцию, потому что мне очень понравилась функция Сёрена Левборга выше (особенно то, как он управлял кодировками), но мне нужно было немного больше функциональности и гибкости.
Может использовать DomDocument в этом случае с неприятным хаком регулярных выражений, худшее, что может произойти, это предупреждение, если есть сломанный тег:
$dom = new DOMDocument();
$dom->loadHTML(substr("Hello, my <strong>name</strong> is <em>Sam</em>. I´m a web developer.",0,26));
$html = preg_replace("/\<\/?(body|html|p)>/", "", $dom->saveHTML());
echo $html;
Должен дать вывод: Hello, my <strong>**name**</strong>
,
Я внес небольшие изменения в Сёрен Левборг printTruncated
функция, делающая его совместимым с UTF-8:
/* Truncate HTML, close opened tags
*
* @param int, maxlength of the string
* @param string, html
* @return $html
*/
function html_truncate($maxLength, $html){
mb_internal_encoding("UTF-8");
$printedLength = 0;
$position = 0;
$tags = array();
ob_start();
while ($printedLength < $maxLength && preg_match('{</?([a-z]+)[^>]*>|&#?[a-zA-Z0-9]+;}', $html, $match, PREG_OFFSET_CAPTURE, $position)){
list($tag, $tagPosition) = $match[0];
// Print text leading up to the tag.
$str = mb_strcut($html, $position, $tagPosition - $position);
if ($printedLength + mb_strlen($str) > $maxLength){
print(mb_strcut($str, 0, $maxLength - $printedLength));
$printedLength = $maxLength;
break;
}
print($str);
$printedLength += mb_strlen($str);
if ($tag[0] == '&'){
// Handle the entity.
print($tag);
$printedLength++;
}
else{
// Handle the tag.
$tagName = $match[1][0];
if ($tag[1] == '/'){
// This is a closing tag.
$openingTag = array_pop($tags);
assert($openingTag == $tagName); // check that tags are properly nested.
print($tag);
}
else if ($tag[mb_strlen($tag) - 2] == '/'){
// Self-closing tag.
print($tag);
}
else{
// Opening tag.
print($tag);
$tags[] = $tagName;
}
}
// Continue after the tag.
$position = $tagPosition + mb_strlen($tag);
}
// Print any remaining text.
if ($printedLength < $maxLength && $position < mb_strlen($html))
print(mb_strcut($html, $position, $maxLength - $printedLength));
// Close any open tags.
while (!empty($tags))
printf('</%s>', array_pop($tags));
$bufferOuput = ob_get_contents();
ob_end_clean();
$html = $bufferOuput;
return $html;
}
Это очень трудно сделать без использования валидатора и парсера, причина в том, что представьте, если у вас есть
<div id='x'>
<div id='y'>
<h1>Heading</h1>
500
lines
of
html
...
etc
...
</div>
</div>
Как вы планируете урезать это и в конечном итоге с правильным HTML?
После недолгого поиска я нашел эту ссылку, которая могла бы помочь.