From 7c025e03519eb8165120ce8f57f12af60bf25f02 Mon Sep 17 00:00:00 2001 From: Piyush <296399266+skikken@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:36:00 +0530 Subject: [PATCH 1/2] Charset: Compare term names by a case- and accent-folded sort key wp_generate_tag_cloud() re-sorts the terms in PHP after the database has already returned them in collation order. The comparison callback _wp_object_name_sort_cb() used strnatcasecmp(), which folds case only for ASCII and ignores accents entirely, so non-ASCII terms were ordered by raw byte value. For Greek that split one alphabet into four consecutive groups: uppercase accented, uppercase non-accented, lowercase accented and lowercase non-accented. With the Latin terms on top, a tag cloud showed five groups instead of the expected two. Fold the name to lowercase and strip combining marks to build the sort key, so case and accent variants of the same letter sort together and match the ordering the database collation already provides. The sort key is left decomposed because recomposing would restore the accents. A deterministic strnatcmp() tie-break on the original name keeps the order well defined when the folded keys are equal, and the callback result is cast to an int, which is what comparison callbacks are expected to return. mbstring and intl are not required by WordPress, so both folds are applied only when the extension is available. The plain strtolower() fallback and the unfolded fallback still keep case variants adjacent, which is the larger part of the reported problem. Plain ASCII ordering is byte for byte unchanged. Props beerallica. Fixes #35144. --- src/wp-includes/category-template.php | 57 ++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/category-template.php b/src/wp-includes/category-template.php index f268f93cbc461..8456fd4840557 100644 --- a/src/wp-includes/category-template.php +++ b/src/wp-includes/category-template.php @@ -1071,6 +1071,8 @@ function wp_generate_tag_cloud( $tags, $args = '' ) { * Used with `uasort()`. * * @since 3.1.0 + * @since 7.2.0 Names are compared using a case- and accent-folded sort key, + * and the callback always returns an integer. * @access private * * @param object $a The first object to compare. @@ -1079,7 +1081,60 @@ function wp_generate_tag_cloud( $tags, $args = '' ) { * or greater than zero if `$a->name` is greater than `$b->name`. */ function _wp_object_name_sort_cb( $a, $b ) { - return strnatcasecmp( $a->name, $b->name ); + $result = strnatcmp( _wp_object_name_sort_key( $a->name ), _wp_object_name_sort_key( $b->name ) ); + + if ( 0 === $result ) { + // Tie-break on the original name so that sorting is deterministic. + $result = strnatcmp( (string) $a->name, (string) $b->name ); + } + + return (int) $result; +} + +/** + * Builds a case- and accent-insensitive sort key for an object name. + * + * `strnatcmp()` compares bytes, so it orders, for example, all uppercase Greek + * letters before all lowercase ones and separates accented letters from their + * unaccented equivalents. Folding the name first keeps such variants in a + * single, natural group. + * + * Both steps are conditional because neither mbstring nor intl is required by + * WordPress. Case folding alone already merges uppercase and lowercase forms; + * stripping combining marks additionally merges accented forms, but only when + * `Normalizer` (intl) is available. + * + * @since 7.2.0 + * @access private + * + * @param string $name The name to build a sort key for. + * @return string The folded sort key. + */ +function _wp_object_name_sort_key( $name ) { + $key = (string) $name; + + if ( function_exists( 'mb_strtolower' ) ) { + $key = mb_strtolower( $key, 'UTF-8' ); + } else { + $key = strtolower( $key ); + } + + if ( class_exists( 'Normalizer' ) ) { + /* + * Decompose into base characters plus combining marks, then drop the marks. + * The key is intentionally left decomposed: recomposing would put the + * accents back and undo the fold. + */ + $decomposed = Normalizer::normalize( $key, Normalizer::FORM_D ); + if ( is_string( $decomposed ) ) { + $stripped = preg_replace( '/\p{Mn}+/u', '', $decomposed ); + if ( is_string( $stripped ) ) { + $key = $stripped; + } + } + } + + return $key; } /** From f94c477408072e9c75534503e19d989af4a959b0 Mon Sep 17 00:00:00 2001 From: Piyush <296399266+skikken@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:36:00 +0530 Subject: [PATCH 2/2] Tests: Cover case- and accent-insensitive tag cloud sorting Add coverage to the existing tag cloud test file for the name ordering in wp_generate_tag_cloud(). Greek terms that differ only by case or by accents must stay in a single group and must not be split apart by the comparison, and Latin terms must still sort before Greek ones. A further test pins the ASCII ordering so the change to the sort key is proven not to alter it, and an additional test asserts the variants of a single letter keep a deterministic order. The accent expectations require the intl extension, so those tests are skipped when Normalizer is unavailable. Fixes #35144. --- .../phpunit/tests/term/wpGenerateTagCloud.php | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) diff --git a/tests/phpunit/tests/term/wpGenerateTagCloud.php b/tests/phpunit/tests/term/wpGenerateTagCloud.php index e2d539299aa46..c8fdfb6a572df 100644 --- a/tests/phpunit/tests/term/wpGenerateTagCloud.php +++ b/tests/phpunit/tests/term/wpGenerateTagCloud.php @@ -1,6 +1,10 @@ assertSame( array( 1, 2, 3 ), array_map( 'intval', $matches[1] ) ); } + /** + * Returns tag objects for the given names, shaped the way `wp_tag_cloud()` + * passes them to `wp_generate_tag_cloud()`. + * + * @param string[] $names Term names. + * @return object[] Tag objects. + */ + protected function get_tags_for_names( array $names ) { + $tags = array(); + + foreach ( array_values( $names ) as $index => $name ) { + $tag = new stdClass(); + $tag->id = $index + 1; + $tag->name = $name; + $tag->slug = 'tag-' . ( $index + 1 ); + $tag->count = 1; + $tag->link = 'https://example.org/?tag=' . $tag->slug; + $tags[] = $tag; + } + + return $tags; + } + + /** + * Generates a tag cloud and returns the term names in their sorted order. + * + * @param string[] $names Term names. + * @return string[] Term names in the order produced by `wp_generate_tag_cloud()`. + */ + protected function get_sorted_names( array $names ) { + $cloud = wp_generate_tag_cloud( $this->get_tags_for_names( $names ), array( 'format' => 'array' ) ); + + return array_map( 'wp_strip_all_tags', $cloud ); + } + + /** + * Greek letters that only differ by case. + * + * Each inner array holds all case variants of a single letter, written as + * codepoints: + * + * U+0391..U+0399 Greek capital letters + * U+03B1..U+03B9 Greek small letters + * + * All variants of a letter belong to one group and must stay together. + * + * @return string[][] + */ + protected function get_greek_case_groups() { + return array( + array( "\u{0391}", "\u{03B1}" ), // Alpha. + array( "\u{0392}", "\u{03B2}" ), // Beta. + array( "\u{0393}", "\u{03B3}" ), // Gamma. + array( "\u{0394}", "\u{03B4}" ), // Delta. + array( "\u{0395}", "\u{03B5}" ), // Epsilon. + array( "\u{0396}", "\u{03B6}" ), // Zeta. + array( "\u{0397}", "\u{03B7}" ), // Eta. + array( "\u{0398}", "\u{03B8}" ), // Theta. + array( "\u{0399}", "\u{03B9}" ), // Iota. + ); + } + + /** + * Greek letters that differ by case and by accents. + * + * Each inner array holds all variants of a single letter, written as + * codepoints: + * + * U+0386, U+0388, U+0389, U+038A capital letter with tonos + * U+0391..U+0399 capital letter + * U+03B1..U+03B9 small letter + * U+03AC..U+03AF small letter with tonos + * U+1F00..U+1F77 small letter with breathing or grave + * U+1FB0, U+1FB1 small alpha with vrachy or macron + * + * Consonants have no polytonic variants, so their groups are smaller than + * the vowel groups. All variants of a letter belong to one group and must + * stay together once combining marks are stripped. + * + * @return string[][] + */ + protected function get_greek_accent_groups() { + return array( + // Alpha. + array( "\u{0386}", "\u{0391}", "\u{03B1}", "\u{03AC}", "\u{1F00}", "\u{1F04}", "\u{1F70}", "\u{1F71}", "\u{1FB0}", "\u{1FB1}" ), + // Beta. + array( "\u{0392}", "\u{03B2}" ), + // Gamma. + array( "\u{0393}", "\u{03B3}" ), + // Delta. + array( "\u{0394}", "\u{03B4}" ), + // Epsilon. + array( "\u{0388}", "\u{0395}", "\u{03B5}", "\u{03AD}", "\u{1F10}", "\u{1F11}", "\u{1F72}", "\u{1F73}" ), + // Zeta. + array( "\u{0396}", "\u{03B6}" ), + // Eta. + array( "\u{0389}", "\u{0397}", "\u{03B7}", "\u{03AE}", "\u{1F20}", "\u{1F21}", "\u{1F74}", "\u{1F75}" ), + // Theta. + array( "\u{0398}", "\u{03B8}" ), + // Iota. + array( "\u{038A}", "\u{0399}", "\u{03B9}", "\u{03AF}", "\u{1F30}", "\u{1F31}", "\u{1F76}", "\u{1F77}" ), + ); + } + + /** + * Asserts that every group of equivalent names stays contiguous and that + * the groups themselves keep the order in which they were supplied. + * + * @param string[][] $groups Groups of equivalent term names. + * @param string[] $sorted_names Term names in sorted order. + */ + protected function assertGroupsAreNotSplit( array $groups, array $sorted_names ) { + $group_of = array(); + foreach ( $groups as $group_index => $names ) { + foreach ( $names as $name ) { + $group_of[ $name ] = $group_index; + } + } + + $found_groups = array(); + foreach ( $sorted_names as $name ) { + $this->assertArrayHasKey( $name, $group_of, "Unexpected term in the sorted output: {$name}" ); + $found_groups[] = $group_of[ $name ]; + } + + $this->assertSame( + array_keys( $groups ), + array_values( array_unique( $found_groups ) ), + 'Groups of equivalent names were split or reordered.' + ); + + $sorted_groups = $found_groups; + sort( $sorted_groups ); + $this->assertSame( $sorted_groups, $found_groups, 'Groups of equivalent names were interleaved.' ); + } + + /** + * Flattens a list of groups into a single list of names. + * + * @param string[][] $groups Groups of equivalent term names. + * @return string[] + */ + protected function flatten_groups( array $groups ) { + $names = array(); + + foreach ( $groups as $group ) { + $names = array_merge( $names, $group ); + } + + return $names; + } + + /** + * The tag cloud is sorted in PHP after SQL has already sorted the terms. + * The comparison must not reorder plain ASCII names. + * + * @ticket 35144 + */ + public function test_ascii_names_keep_their_natural_order() { + $names = array( 'banana', 'apple', 'Cherry', 'item10', 'item9', 'Item2', 'zebra', 'Zulu', 'alpha' ); + + $this->assertSame( + array( 'alpha', 'apple', 'banana', 'Cherry', 'Item2', 'item9', 'item10', 'zebra', 'Zulu' ), + $this->get_sorted_names( $names ) + ); + } + + /** + * Latin terms must keep sorting before Greek ones. + * + * @ticket 35144 + */ + public function test_latin_names_sort_before_greek_names() { + $names = array( 'zebra', "\u{0386}\u{03BB}\u{03C6}\u{03B1}", 'Apple', "\u{03C9}\u{03BC}\u{03AD}\u{03B3}\u{03B1}", 'beta' ); + + $this->assertSame( + array( 'Apple', 'beta', 'zebra', "\u{0386}\u{03BB}\u{03C6}\u{03B1}", "\u{03C9}\u{03BC}\u{03AD}\u{03B3}\u{03B1}" ), + $this->get_sorted_names( $names ) + ); + } + + /** + * Uppercase and lowercase Greek letters must not be split into separate + * groups by the byte-oriented comparison. + * + * @ticket 35144 + */ + public function test_greek_case_variants_are_not_split_into_groups() { + $groups = $this->get_greek_case_groups(); + + $this->assertGroupsAreNotSplit( $groups, $this->get_sorted_names( $this->flatten_groups( $groups ) ) ); + } + + /** + * Accents must not split Greek letters into separate groups either. + * + * Folding accents requires the intl extension, which WordPress does not + * require, so this test is skipped when it is unavailable. + * + * @ticket 35144 + */ + public function test_greek_accent_variants_are_not_split_into_groups() { + if ( ! class_exists( 'Normalizer' ) ) { + $this->markTestSkipped( 'This test requires the intl extension.' ); + } + + $groups = $this->get_greek_accent_groups(); + + $this->assertGroupsAreNotSplit( $groups, $this->get_sorted_names( $this->flatten_groups( $groups ) ) ); + } + + /** + * The variants of a single Greek letter form one group, ordered + * deterministically by the original name whenever their sort keys match. + * + * @ticket 35144 + */ + public function test_greek_alpha_variants_form_a_single_deterministic_group() { + if ( ! class_exists( 'Normalizer' ) ) { + $this->markTestSkipped( 'This test requires the intl extension.' ); + } + + $names = array( "\u{1FB1}", "\u{03AC}", "\u{0386}", "\u{1FB0}", "\u{1F70}", "\u{1F04}", "\u{1F00}", "\u{03B1}" ); + + $this->assertSame( + array( "\u{0386}", "\u{03AC}", "\u{03B1}", "\u{1F00}", "\u{1F04}", "\u{1F70}", "\u{1FB0}", "\u{1FB1}" ), + $this->get_sorted_names( $names ) + ); + } + /** * Helper method retrieve the created terms. *