Усекать текст, содержащий HTML, игнорируя теги

Я хочу усечь некоторый текст (загруженный из базы данных или текстового файла), но он содержит HTML, в результате теги включаются и меньше текста будет возвращено. Это может привести к тому, что теги не будут закрыты или частично закрыты (поэтому Tidy может работать неправильно, а контент еще меньше). Как я могу обрезать на основе текста (и, вероятно, останавливаться, когда вы попадаете в таблицу, поскольку это может вызвать более сложные проблемы).

substr("Hello, my <strong>name</strong> is <em>Sam</em>. I&acute;ma web developer.",0,26)."..." 

Это приведет к:

 Hello, my <strong>name</st... 

Я бы хотел:

 Hello, my <strong>name</strong> is <em>Sam</em>. I&acute;m... 

Как я могу это сделать?

Хотя мой вопрос заключается в том, как это сделать на PHP, было бы хорошо знать, как это сделать в C # … либо должно быть ОК, как я думаю, я смог бы переносить этот метод (если он не встроен в метод).

Также обратите внимание, что я включил объект HTML &acute; – который должен рассматриваться как один символ (а не 7 символов, как в этом примере).

strip_tags является резервным, но я бы потерял форматирование и ссылки, и у него все еще возникнет проблема с объектами HTML.

Предполагая, что вы используете действительный 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 ? '{</?([az]+)[^>]*>|&#?[a-zA-Z0-9]+;|[\x80-\xFF][\x80-\xBF]*}' : '{</?([az]+)[^>]*>|&#?[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>&lt;Hello&gt;</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>&#20;w\xC3\xB8rld!</em>"); print("\n"); 

Замечание по кодированию : приведенный выше код предполагает, что XHTML кодируется в кодировке UTF-8 . Также поддерживаются ASCII-совместимые однобайтовые кодировки (такие как Latin-1 ), просто передайте false в качестве третьего аргумента. Другие многобайтовые кодировки не поддерживаются, хотя вы можете взломать поддержку с помощью mb_convert_encoding для преобразования в UTF-8 перед вызовом функции, а затем конвертировать обратно в каждый оператор print .

(Вы всегда должны использовать UTF-8.)

Изменить : Обновлено для обработки объектов символов и UTF-8. Исправлена ​​ошибка, при которой функция печатала один символ слишком много, если этот символ был символьной сущностью.

Я использовал хорошую функцию, найденную по адресу http://alanwhipple.com/2011/05/25/php-truncate-string-preserving-html-tags-words , по-видимому взятой из CakePHP

100% точный, но довольно сложный подход:

  1. Итерации с использованием DOM
  2. Использовать методы DOM для удаления остальных элементов
  3. Сериализовать DOM

Легкий подход грубой силы:

  1. Разделите строку на теги (а не на элементы) и текстовые фрагменты, используя preg_split('/(<tag>)/') с помощью PREG_DELIM_CAPTURE.
  2. Измерить нужную длину текста (это будет каждый второй элемент из split, вы можете использовать html_entity_decode() чтобы точно измерить)
  3. Вырежьте строку (trim &[^\s;]+$ в конце, чтобы избавиться от возможно измельченной сущности)
  4. Исправьте его с помощью HTML Tidy

Я написал функцию, которая усекает 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 = '&hellip;'){ //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; } 

В этом случае можно использовать DomDocument с неприятным взломом регулярных выражений, худшее, что произойдет, это предупреждение, если есть сломанный тег:

 $dom = new DOMDocument(); $dom->loadHTML(substr("Hello, my <strong>name</strong> is <em>Sam</em>. I&acute;ma web developer.",0,26)); $html = preg_replace("/\<\/?(body|html|p)>/", "", $dom->saveHTML()); echo $html; 

Должен выдавать результат: Hello, my <strong>**name**</strong> .

Bounce добавила многобайтную поддержку персонажа к решению Søren Løvborg – я добавил:

  • поддержка непарных HTML-тегов (например, <hr> , <br> <col> и т. д. не закрываются – в HTML a '/' не требуется в конце этих (in is for XHTML хотя)),
  • настраиваемый индикатор усечения (по умолчанию – &hellips; т. е. …),
  • возвращать в виде строки без использования выходного буфера и
  • модульные тесты со 100% охватом.

Все это в Pastie .

вы также можете использовать аккуратно :

 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> -tag). Таким образом, это не может считаться 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; } 

Я сделал легкие изменения в функции Søren Løvborg 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('{</?([az]+)[^>]*>|&#?[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; } 

Другой свет меняется на Søren Løvborg printTruncated, что делает совместимость UTF-8 (требуется mbstring) и делает его возвратной строкой, а не печатью. Я думаю, что это более полезно. И мой код не использует буферизацию, как вариант Bounce, еще одну переменную.

UPD: чтобы он работал правильно с помощью символов utf-8 в атрибутах тегов, вам нужна функция mb_preg_match, перечисленная ниже.

Большое спасибо Søren Løvborg за эту функцию, это очень хорошо.

 /* 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('{</?([az]+)[^>]*>|&#?[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; } 

В CakePHP framework есть функция truncate (), поддерживающая HTML, в TextHelper, которая работает для меня. См. Core-Helpers / Text . Лицензия MIT.

Это очень трудно обойтись без использования валидатора и анализатора, причина в том, что представьте, если у вас есть

 <div id='x'> <div id='y'> <h1>Heading</h1> 500 lines of html ... etc ... </div> </div> 

Как вы планируете обрезать это и заканчивать действительным HTML?

После краткого поиска я нашел эту ссылку, которая могла бы помочь.