diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php
index ecae45083a6ca..511dd6fe64af2 100644
--- a/src/wp-includes/formatting.php
+++ b/src/wp-includes/formatting.php
@@ -3612,49 +3612,91 @@ function translate_smiley( $matches ) {
function convert_smilies( $text ) {
global $wp_smiliessearch;
- if ( ! get_option( 'use_smilies' ) || empty( $wp_smiliessearch ) ) {
+ if ( ! is_string( $text ) || ! get_option( 'use_smilies' ) || empty( $wp_smiliessearch ) ) {
// Return default text.
return $text;
}
- // HTML loop taken from texturize function, could possible be consolidated.
- $textarr = preg_split( '/(<[^>]*>)/U', $text, -1, PREG_SPLIT_DELIM_CAPTURE ); // Capture the tags as well as in between.
-
- if ( false === $textarr ) {
- // Return default text.
- return $text;
+ // Without any markup the whole text is a single text node.
+ if ( ! str_contains( $text, '<' ) ) {
+ return preg_replace_callback( $wp_smiliessearch, 'translate_smiley', $text ) ?? $text;
}
- // Loop stuff.
- $stop = count( $textarr );
+ // Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans.
+ $processor = new class( $text ) extends WP_HTML_Tag_Processor {
+ /**
+ * Gets the span for the current token.
+ *
+ * @return WP_HTML_Span Current token span.
+ */
+ public function get_span(): WP_HTML_Span {
+ // Always called while matched on a token, so setting the bookmark never fails.
+ $this->set_bookmark( 'here' );
+ return $this->bookmarks['here'];
+ }
+ };
+
+ /*
+ * The smilies are replaced by raw HTML, which set_modifiable_text() would
+ * escape, so the output is assembled here from the unchanged parts of the
+ * text and the converted text nodes.
+ */
$output = '';
+ $was_at = 0;
- // Ignore processing of specific tags.
- $tags_to_ignore = 'code|pre|style|script|textarea';
- $ignore_block_element = '';
+ /*
+ * Smilies are not converted inside CODE and PRE elements. The Tag Processor
+ * does not track document structure, so the open CODE and PRE elements are
+ * tracked here. Text is ignored for as long as any of them is open.
+ */
+ $ignored_tags = array();
- for ( $i = 0; $i < $stop; $i++ ) {
- $content = $textarr[ $i ];
+ while ( $processor->next_token() ) {
+ $token_type = $processor->get_token_type();
- // If we're in an ignore block, wait until we find its closing tag.
- if ( '' === $ignore_block_element && preg_match( '/^<(' . $tags_to_ignore . ')[^>]*>/', $content, $matches ) ) {
- $ignore_block_element = $matches[1];
+ if ( '#tag' === $token_type ) {
+ $tag_name = $processor->get_tag();
+ $is_closer = $processor->is_tag_closer();
+
+ // A closing tag is unexpected when no CODE or PRE element is open.
+ if ( ( 'CODE' !== $tag_name && 'PRE' !== $tag_name ) || ( $is_closer && 0 === count( $ignored_tags ) ) ) {
+ continue;
+ }
+
+ if ( ! $is_closer ) {
+ $ignored_tags[] = $tag_name;
+ continue;
+ }
+
+ /*
+ * A closing tag closes the nearest open element of its own name and
+ * leaves the others open. A CODE element which is still open when its
+ * containing PRE closes continues into the text which follows.
+ */
+ $opener_at = array_search( $tag_name, array_reverse( $ignored_tags, true ), true );
+ if ( false !== $opener_at ) {
+ array_splice( $ignored_tags, $opener_at, 1 );
+ }
+ continue;
}
- // If it's not a tag and not in ignore block.
- if ( '' === $ignore_block_element && strlen( $content ) > 0 && '<' !== $content[0] ) {
- $content = preg_replace_callback( $wp_smiliessearch, 'translate_smiley', $content );
+ if ( '#text' !== $token_type || count( $ignored_tags ) > 0 ) {
+ continue;
}
- // Did we exit ignore block?
- if ( '' !== $ignore_block_element && '' . $ignore_block_element . '>' === $content ) {
- $ignore_block_element = '';
+ $span = $processor->get_span();
+ $content = substr( $text, $span->start, $span->length );
+ $converted = preg_replace_callback( $wp_smiliessearch, 'translate_smiley', $content );
+
+ if ( null === $converted || $converted === $content ) {
+ continue;
}
- $output .= $content;
+ $output .= substr( $text, $was_at, $span->start - $was_at ) . $converted;
+ $was_at = $span->start + $span->length;
}
- return $output;
+ return $output . substr( $text, $was_at );
}
/**
@@ -6228,52 +6270,100 @@ function wp_staticize_emoji( $text ) {
/** This filter is documented in wp-includes/formatting.php */
$ext = apply_filters( 'emoji_ext', '.png' );
+ // Anonymous subclass of WP_HTML_Tag_Processor which exposes underlying bookmark spans.
+ $processor = new class( $text ) extends WP_HTML_Tag_Processor {
+ /**
+ * Gets the span for the current token.
+ *
+ * @return WP_HTML_Span Current token span.
+ */
+ public function get_span(): WP_HTML_Span {
+ // Always called while matched on a token, so setting the bookmark never fails.
+ $this->set_bookmark( 'here' );
+ return $this->bookmarks['here'];
+ }
+ };
+
+ /*
+ * The emoji are replaced by raw HTML, which set_modifiable_text() would
+ * escape, so the output is assembled here from the unchanged parts of the
+ * text and the converted text nodes.
+ */
$output = '';
+ $was_at = 0;
+
/*
- * HTML loop taken from smiley function, which was taken from texturize function.
- * It'll never be consolidated.
- *
- * First, capture the tags as well as in between.
+ * Emoji are not converted inside CODE and PRE elements. The Tag Processor
+ * does not track document structure, so the open CODE and PRE elements are
+ * tracked here. Text is ignored for as long as any of them is open.
*/
- $textarr = preg_split( '/(<.*>)/U', $text, -1, PREG_SPLIT_DELIM_CAPTURE );
- $stop = count( $textarr );
+ $ignored_tags = array();
- // Ignore processing of specific tags.
- $tags_to_ignore = 'code|pre|style|script|textarea';
- $ignore_block_element = '';
+ while ( $processor->next_token() ) {
+ $token_type = $processor->get_token_type();
- for ( $i = 0; $i < $stop; $i++ ) {
- $content = $textarr[ $i ];
+ if ( '#tag' === $token_type ) {
+ $tag_name = $processor->get_tag();
+ $is_closer = $processor->is_tag_closer();
- // If we're in an ignore block, wait until we find its closing tag.
- if ( '' === $ignore_block_element && preg_match( '/^<(' . $tags_to_ignore . ')>/', $content, $matches ) ) {
- $ignore_block_element = $matches[1];
+ // A closing tag is unexpected when no CODE or PRE element is open.
+ if ( ( 'CODE' !== $tag_name && 'PRE' !== $tag_name ) || ( $is_closer && 0 === count( $ignored_tags ) ) ) {
+ continue;
+ }
+
+ if ( ! $is_closer ) {
+ $ignored_tags[] = $tag_name;
+ continue;
+ }
+
+ /*
+ * A closing tag closes the nearest open element of its own name and
+ * leaves the others open. A CODE element which is still open when its
+ * containing PRE closes continues into the text which follows.
+ */
+ $opener_at = array_search( $tag_name, array_reverse( $ignored_tags, true ), true );
+ if ( false !== $opener_at ) {
+ array_splice( $ignored_tags, $opener_at, 1 );
+ }
+ continue;
}
- // If it's not a tag and not in ignore block.
- if ( '' === $ignore_block_element && strlen( $content ) > 0 && '<' !== $content[0] && str_contains( $content, '' ) ) {
- foreach ( $possible_emoji as $emojum => $emoji_char ) {
- if ( ! str_contains( $content, $emojum ) ) {
- continue;
- }
+ if ( '#text' !== $token_type || count( $ignored_tags ) > 0 ) {
+ continue;
+ }
+
+ $span = $processor->get_span();
+ $content = substr( $text, $span->start, $span->length );
- $file = str_replace( ';', '-', $emojum );
- $file = str_replace( array( '', ';' ), '', $file );
+ if ( ! str_contains( $content, '' ) ) {
+ continue;
+ }
- $entity = sprintf( '', $cdn_url . $file . $ext, $emoji_char );
+ $converted = $content;
- $content = str_replace( $emojum, $entity, $content );
+ foreach ( $possible_emoji as $emojum => $emoji_char ) {
+ if ( ! str_contains( $converted, $emojum ) ) {
+ continue;
}
+
+ $file = str_replace( ';', '-', $emojum );
+ $file = str_replace( array( '', ';' ), '', $file );
+
+ $entity = sprintf( '
', $cdn_url . $file . $ext, $emoji_char );
+
+ $converted = str_replace( $emojum, $entity, $converted );
}
- // Did we exit ignore block?
- if ( '' !== $ignore_block_element && '' . $ignore_block_element . '>' === $content ) {
- $ignore_block_element = '';
+ if ( $converted === $content ) {
+ continue;
}
- $output .= $content;
+ $output .= substr( $text, $was_at, $span->start - $was_at ) . $converted;
+ $was_at = $span->start + $span->length;
}
+ $output .= substr( $text, $was_at );
+
// Finally, remove any stray U+FE0F characters.
$output = str_replace( '️', '', $output );
diff --git a/tests/phpunit/tests/formatting/convertSmilies.php b/tests/phpunit/tests/formatting/convertSmilies.php
index fa9ce713f968f..23e99e31a08ab 100644
--- a/tests/phpunit/tests/formatting/convertSmilies.php
+++ b/tests/phpunit/tests/formatting/convertSmilies.php
@@ -163,6 +163,49 @@ public static function data_ignore_smilies_in_tags() {
);
}
+ /**
+ * Tests where the ignore block starts and ends, by counting converted smilies.
+ *
+ * @ticket 66134
+ *
+ * @dataProvider data_ignore_smilies_block_boundaries
+ *
+ * @param string $input The content to convert.
+ * @param int $expected The expected number of converted smilies.
+ */
+ public function test_ignore_smilies_block_boundaries( $input, $expected ) {
+ $this->assertSame( $expected, substr_count( convert_smilies( $input ), "\xf0\x9f\x99\x82" ) );
+ }
+
+ /**
+ * Data provider.
+ *
+ * @return array[]
+ */
+ public static function data_ignore_smilies_block_boundaries() {
+ return array(
+ 'smiley after closing tag is converted' => array( '
:):)', 1 ), + 'uppercase tags' => array( '
:):)', 1 ), + 'whitespace in closing tag' => array( '
:):)', 1 ), + 'custom element starting with ignored name' => array( '
:)', 1 ),
+ 'ignored tag with greater-than in attribute' => array( ':):)', 1 ), + 'closing tag lookalike in attribute value' => array( '
:) :)', 1 ),
+ 'smiley inside HTML comment' => array( ' :)', 1 ),
+ 'ignored tag inside HTML comment' => array( ' :)', 1 ),
+ 'closing tag inside HTML comment' => array( ':):)', 1 ), + 'nested ignored tags' => array( '
:) :) :)', 1 ),
+ 'same ignored tag nested' => array( ' :) :) :)', 1 ),
+ 'ignored tag still open after its parent' => array( ' :) :) :)', 1 ),
+ 'ignored tag never closed inside its parent' => array( ' :) :)', 0 ),
+ 'parent still open after ignored tag closes' => array( ' :)
:) :)', 1 ),
+ 'unmatched closing tag inside ignored tag' => array( ':) :):)', 1 ), + 'less-than sign in text' => array( '1 < 2 :)
:)
', 2 ), + ); + } + /** * Tests that combinations of smilies separated by a single space * are converted correctly. @@ -373,4 +416,31 @@ public function test_smilies_with_large_text_input() { $text = '🙂🙂', 1 ), + 'code block markup' => array( '
🙂', 0 ),
+ 'raw emoji character' => array( "\u{1F642}\u{1F642}", 1 ),
+ 'slash after tag name' => array( '🙂🙂', 1 ),
+ 'uppercase tags' => array( '🙂🙂', 1 ), + 'whitespace in closing tag' => array( '
🙂🙂', 1 ), + 'custom element starting with ignored name' => array( '
🙂', 1 ),
+ 'ignored tag with greater-than in attribute' => array( '🙂🙂', 1 ), + 'closing tag lookalike in attribute value' => array( '
🙂🙂', 1 ),
+ 'emoji inside HTML comment' => array( '🙂', 1 ),
+ 'ignored tag inside HTML comment' => array( '🙂', 1 ),
+ 'closing tag inside HTML comment' => array( '🙂🙂', 1 ), + 'nested ignored tags' => array( '
🙂🙂🙂', 1 ),
+ 'same ignored tag nested' => array( '🙂🙂🙂', 1 ),
+ 'ignored tag still open after its parent' => array( '🙂🙂🙂', 1 ),
+ 'ignored tag never closed inside its parent' => array( '🙂🙂', 0 ),
+ 'parent still open after ignored tag closes' => array( '🙂
🙂🙂', 1 ),
+ 'unmatched closing tag inside ignored tag' => array( '🙂🙂🙂', 1 ), + 'less-than sign in text' => array( '1 < 2 🙂
🙂
', 2 ), + ); + } }