How to Trim Strings and Keep HTML Tags – Snippet Included
There are a lot of methods for trimming strings that take into account the number of chars versus the number of words, but things get a bit complicated when we need to keep the HTML tags at the same time. Don’t they?
Consistent Appearance
Most of the time, when displaying content for archives, one of the challenges is to make all the items look similar, or have almost the same size. This is directly related to the content of each post.
We can use the WordPress native function wp_trim_words that allows us to get a substring that contains a specified number of words. That is not working as we intend all the time, as some words a longer, and the resulting string after applying this type of trimming can vary to a great degree.
How to Solve This?
In order to get more uniformity for the items we display, we are aiming to use a function that takes into account the number of chars versus the number of words. There are a lot of methods to do so, but things get a bit complicated when we need to keep the markup at the same time (like preserving tags, for example, links, bold or italic text, etc.).
PHP Snippet
This is a simple snippet you can use to achieve this.
/**
* Truncate HTML while preserving allowed tags and valid markup. The maximum
* length applies to visible text only, excluding HTML tags and the optional
* ending string.
*
* @param string $string Original HTML.
* @param int $max_len Maximum visible characters.
* @param string $end_string String appended when truncated.
* @param string $allow_tags Allowed tags, in strip_tags() format.
* @param bool $break Whether to break the last word.
* @return string
*/
function get_short_html( $string, $max_len = 80, $end_string = '...', $allow_tags = '<a><b><strong><em><i>', $break = false ) {
if ( ! is_string( $string ) || '' === $string ) {
return '';
}
$max_len = max( 0, (int) $max_len );
// Remove shortcodes and normalize whitespace.
$string = strip_shortcodes( $string );
$string = str_replace( [ "\r\n", "\r", "\n", "\t" ], ' ', $string );
$string = preg_replace( '/[\x00-\x1F\x7F]/u', '', $string );
$string = preg_replace( '/[^\S\x{00A0}]+/u', ' ', $string );
// Build a safe list of allowed HTML elements.
$allowed_html = [];
$wp_allowed = wp_kses_allowed_html( 'post' );
preg_match_all( '/<([a-z][a-z0-9-]*)>/i', $allow_tags, $matches );
foreach ( $matches[1] as $tag ) {
$tag = strtolower( $tag );
if ( isset( $wp_allowed[ $tag ] ) ) {
$allowed_html[ $tag ] = $wp_allowed[ $tag ];
}
}
$string = trim( wp_kses( $string, $allowed_html ) );
if ( '' === $string ) {
return '';
}
// Parse the sanitized HTML inside a temporary wrapper.
$dom = new DOMDocument( '1.0', 'UTF-8' );
$previous = libxml_use_internal_errors( true );
try {
$loaded = $dom->loadHTML(
'<?xml encoding="UTF-8"?><div id="short-html-root">' . $string . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
} finally {
libxml_clear_errors();
libxml_use_internal_errors( $previous );
}
if ( ! $loaded ) {
return '';
}
$root = $dom->getElementById( 'short-html-root' );
if ( ! $root ) {
return '';
}
// Calculate the length using only visible text.
$plain_text = $root->textContent;
$total_len = mb_strlen( $plain_text, 'UTF-8' );
// No truncation is needed.
if ( $total_len <= $max_len ) {
$result = '';
foreach ( $root->childNodes as $child ) {
$result .= $dom->saveHTML( $child );
}
return trim( $result );
}
// Determine the actual character cutoff.
$cutoff = $max_len;
if ( ! $break && $cutoff > 0 ) {
$prefix = mb_substr( $plain_text, 0, $cutoff, 'UTF-8' );
$next = mb_substr( $plain_text, $cutoff, 1, 'UTF-8' );
// If the cutoff is inside a word, find its beginning.
if (
! preg_match( '/\s/u', $next ) &&
! preg_match( '/\s$/u', $prefix )
) {
if ( preg_match_all( '/\s+/u', $prefix, $spaces, PREG_OFFSET_CAPTURE ) ) {
$last = end( $spaces[0] );
$cutoff = mb_strlen( substr( $prefix, 0, $last[1] ), 'UTF-8' );
} else {
$cutoff = 0;
}
}
}
// Remove trailing whitespace from the cutoff.
if ( $cutoff > 0 ) {
$prefix = mb_substr( $plain_text, 0, $cutoff, 'UTF-8' );
$prefix = preg_replace( '/\s+$/u', '', $prefix );
$cutoff = mb_strlen( $prefix, 'UTF-8' );
}
if ( 0 === $cutoff ) {
return '';
}
// Create a new document containing only the retained content.
$output = new DOMDocument( '1.0', 'UTF-8' );
$holder = $output->createElement( 'div' );
$output->appendChild( $holder );
$remaining = $cutoff;
// Recursively copy nodes, truncating text nodes when necessary.
$copy_node = function ( $node, $parent ) use ( &$copy_node, $output, &$remaining ) {
if ( $remaining <= 0 ) {
return;
}
if ( XML_TEXT_NODE === $node->nodeType ) {
$text = $node->nodeValue;
$length = mb_strlen( $text, 'UTF-8' );
$text = mb_substr(
$text,
0,
min( $length, $remaining ),
'UTF-8'
);
if ( '' !== $text ) {
$parent->appendChild( $output->createTextNode( $text ) );
}
$remaining -= min( $length, $remaining );
return;
}
if ( XML_ELEMENT_NODE !== $node->nodeType ) {
return;
}
$element = $output->importNode( $node, false );
foreach ( $node->childNodes as $child ) {
if ( $remaining <= 0 ) {
break;
}
$copy_node( $child, $element );
}
// Don't preserve empty elements created by truncation.
if ( $element->hasChildNodes() ) {
$parent->appendChild( $element );
}
};
foreach ( $root->childNodes as $child ) {
if ( $remaining <= 0 ) {
break;
}
$copy_node( $child, $holder );
}
// Serialize the inner HTML without the temporary wrapper.
$result = '';
foreach ( $holder->childNodes as $child ) {
$result .= $output->saveHTML( $child );
}
$result = trim( $result );
// Append the ending only when content was actually truncated.
if ( '' !== $result && '' !== $end_string ) {
$result .= ' ' . $end_string;
}
return $result;
}
The function above allows you to keep only the HTML tags you want and works for both self-closed tags and container tags, to append a trailing string (like read more, etc.), trim to a fixed length or a very close length without breaking the last word.