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

If you would like to support my work, consider making a donation, buy me a coffee, or share this on your feed.
A huge thanks in advance!

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.

Iulia Cazan
Privacy Overview

This website uses cookies so that we can provide you with the best user experience possible. Cookie information is stored in your browser and performs functions such as recognising you when you return to our website and helping our team to understand which sections of the website you find most interesting and useful.