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 && '' === $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, '&#x' ) ) { - 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( ';&#x', '-', $emojum ); - $file = str_replace( array( '&#x', ';' ), '', $file ); + if ( ! str_contains( $content, '&#x' ) ) { + continue; + } - $entity = sprintf( '%s', $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( ';&#x', '-', $emojum ); + $file = str_replace( array( '&#x', ';' ), '', $file ); + + $entity = sprintf( '%s', $cdn_url . $file . $ext, $emoji_char ); + + $converted = str_replace( $emojum, $entity, $converted ); } - // Did we exit ignore block? - if ( '' !== $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( ' :) :)', 2 ), + 'unknown element starting with ignored name' => array( ' :) :)', 2 ), + 'element name prefixed by ignored script name' => array( ' :) :)', 2 ), + 'greater-than sign in attribute value' => array( '1 > 0 :) :)', 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 = '

:)'; $this->assertStringContainsString( "\xf0\x9f\x99\x82", convert_smilies( $text ) ); } + + /** + * Tests that a non-string value is returned as is, rather than causing a fatal error. + * + * @ticket 66134 + * + * @dataProvider data_non_string_input_is_returned_unchanged + * + * @param mixed $input The non-string value to pass. + */ + public function test_non_string_input_is_returned_unchanged( $input ) { + $this->assertSame( $input, convert_smilies( $input ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_non_string_input_is_returned_unchanged() { + return array( + 'null' => array( null ), + 'false' => array( false ), + 'integer' => array( 123 ), + 'array' => array( array( ':)' ) ), + ); + } } diff --git a/tests/phpunit/tests/formatting/emoji.php b/tests/phpunit/tests/formatting/emoji.php index 56ffa301a591c..df47f1ae8698d 100644 --- a/tests/phpunit/tests/formatting/emoji.php +++ b/tests/phpunit/tests/formatting/emoji.php @@ -376,4 +376,87 @@ public static function data_wp_staticize_emoji() { public function test_wp_staticize_emoji( $emoji, $expected ) { $this->assertSame( $expected, wp_staticize_emoji( $emoji ) ); } + + /** + * Tests that emoji inside ignored tags are not staticized, with or without attributes. + * + * @ticket 66134 + * @dataProvider data_wp_staticize_emoji_ignored_tags + * + * @covers ::wp_staticize_emoji + * + * @param string $element The ignored element name. + */ + public function test_wp_staticize_emoji_ignores_tags_with_attributes( $element ) { + // U+1F642 as the HTML entity produced by _wp_emoji_list( 'entities' ). + $emoji = '🙂'; + + $no_attr = "<$element>$emoji"; + $this->assertSame( $no_attr, wp_staticize_emoji( $no_attr ), "Emoji inside <$element> should not be staticized." ); + + $with_attr = "<$element data-foo=\"bar\">$emoji"; + $this->assertSame( $with_attr, wp_staticize_emoji( $with_attr ), "Emoji inside <$element data-foo=\"...\"> should not be staticized." ); + } + + /** + * Data provider for test_wp_staticize_emoji_ignores_tags_with_attributes(). + * + * @return array[] + */ + public static function data_wp_staticize_emoji_ignored_tags() { + return array( + 'code' => array( 'code' ), + 'pre' => array( 'pre' ), + 'style' => array( 'style' ), + 'script' => array( 'script' ), + 'textarea' => array( 'textarea' ), + ); + } + + /** + * Tests where the ignore block starts and ends, by counting staticized emoji. + * + * @ticket 66134 + * @dataProvider data_wp_staticize_emoji_ignore_block_boundaries + * + * @covers ::wp_staticize_emoji + * + * @param string $text The content to staticize. + * @param int $expected The expected number of staticized emoji. + */ + public function test_wp_staticize_emoji_ignore_block_boundaries( $text, $expected ) { + $this->assertSame( $expected, substr_count( wp_staticize_emoji( $text ), 'class="wp-smiley"' ) ); + } + + /** + * Data provider for test_wp_staticize_emoji_ignore_block_boundaries(). + * + * @return array[] + */ + public static function data_wp_staticize_emoji_ignore_block_boundaries() { + return array( + 'emoji after closing tag is staticized' => array( '
🙂
🙂', 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( '🙂🙂', 2 ), + 'unknown element starting with ignored name' => array( '🙂🙂', 2 ), + 'element name prefixed by ignored script name' => array( '🙂🙂', 2 ), + 'greater-than sign in attribute value' => array( '1 > 0 🙂🙂', 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 ), + ); + } }