Commit ecb4c8b5d56 for woocommerce
commit ecb4c8b5d56063d6860115d411ea9227a2af4a07
Author: Mike Jolley <mike.jolley@me.com>
Date: Fri Oct 23 13:12:52 2026 +0100
Fix accented state names showing as HTML entities in addresses (#69992)
* Fix state names stored as HTML entities breaking in formatted addresses
Some translations store accented state names as HTML entities, such as
"Ancône" in French. Address formats that use {state_upper} turned
that into "ANC&OCIRC;NE", which esc_html() then escaped again, so My
Account, orders and emails showed the raw entity. Decode the state and
country names before they are uppercased and escaped.
Props @CacheMeOwside for the original fix in #61040.
Co-authored-by: CacheMeOwside <64858136+CacheMeOwside@users.noreply.github.com>
* Add changelog entry for state name entity fix
* Use $sut in the formatted address entity tests
* Mention country names in the state name entity changelog entry
---------
Co-authored-by: CacheMeOwside <64858136+CacheMeOwside@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/fix-45587-state-name-entities b/plugins/woocommerce/changelog/fix-45587-state-name-entities
new file mode 100644
index 00000000000..6fe69910d2c
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-45587-state-name-entities
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Show accented state and country names correctly in addresses when a translation stores them as HTML entities.
diff --git a/plugins/woocommerce/includes/class-wc-countries.php b/plugins/woocommerce/includes/class-wc-countries.php
index 0fe7d28631d..2fb18187e66 100644
--- a/plugins/woocommerce/includes/class-wc-countries.php
+++ b/plugins/woocommerce/includes/class-wc-countries.php
@@ -839,6 +839,10 @@ class WC_Countries {
// Handle full state name.
$full_state = ( $country && $state && isset( $this->read_geo_property( 'states' )[ $country ][ $state ] ) ) ? $this->read_geo_property( 'states' )[ $country ][ $state ] : $state;
+ // Translations can store accented names as HTML entities, such as "Ancône". Decode them so uppercasing and escaping below don't break them.
+ $full_country = html_entity_decode( $full_country, ENT_QUOTES, get_bloginfo( 'charset' ) );
+ $full_state = html_entity_decode( $full_state, ENT_QUOTES, get_bloginfo( 'charset' ) );
+
// Substitute address parts into the string.
$replace = array_map(
'esc_html',
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php b/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php
index 5e3548c6ee8..b32febd2f0e 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-countries-test.php
@@ -1008,6 +1008,55 @@ class WC_Countries_Test extends \WC_Unit_Test_Case {
$this->assertFalse( $fields['billing_state']['required'], 'Macao has no subdivisions, so the state should not be required.' );
}
+ /**
+ * @testdox 'get_formatted_address' outputs translated state names stored as HTML entities as text, including in uppercase.
+ */
+ public function test_get_formatted_address_decodes_entity_encoded_state_names(): void {
+ $this->translate_strings( array( 'Ancona' => 'Ancône' ) );
+ $this->use_address_format( 'IT', "{state}\n{state_upper}" );
+ $sut = new WC_Countries();
+
+ $address = $sut->get_formatted_address(
+ array(
+ 'state' => 'AN',
+ 'country' => 'IT',
+ ),
+ "\n"
+ );
+
+ $this->assertSame( "Ancône\nANCÔNE", $address, 'The state should not be output as entities, or escaped twice once uppercased.' );
+ }
+
+ /**
+ * @testdox 'get_formatted_address' outputs country names stored as HTML entities as text, including in uppercase.
+ */
+ public function test_get_formatted_address_decodes_entity_encoded_country_names(): void {
+ $this->use_address_format( 'CW', "{country}\n{country_upper}" );
+ $sut = new WC_Countries();
+
+ $address = $sut->get_formatted_address( array( 'country' => 'CW' ), "\n" );
+
+ $this->assertSame( "Curaçao\nCURAÇAO", $address, 'The country should not be output as entities, or escaped twice once uppercased.' );
+ }
+
+ /**
+ * @testdox 'get_formatted_address' escapes markup in a translated state name after decoding its entities.
+ */
+ public function test_get_formatted_address_escapes_markup_in_entity_encoded_state_names(): void {
+ $this->translate_strings( array( 'Ancona' => 'Anc<b>ne' ) );
+ $this->use_address_format( 'IT', '{state_upper}' );
+ $sut = new WC_Countries();
+
+ $address = $sut->get_formatted_address(
+ array(
+ 'state' => 'AN',
+ 'country' => 'IT',
+ )
+ );
+
+ $this->assertSame( 'ANC<B>NE', $address, 'Decoded markup should be escaped again for output.' );
+ }
+
/**
* Provider for `test_get_country_from_alpha_3_code`.
*
@@ -1195,6 +1244,38 @@ class WC_Countries_Test extends \WC_Unit_Test_Case {
$this->assertSame( $stamp, $sut->get_country_locale()['US']['postcode']['label'], "Country locale settings should use the $stamp locale." );
}
+ /**
+ * Translate WooCommerce strings, as a language pack would.
+ *
+ * @param array<string, string> $translations Translations keyed by the original string.
+ */
+ private function translate_strings( array $translations ): void {
+ add_filter(
+ 'gettext',
+ function ( $translation, $text, $domain ) use ( $translations ) {
+ return 'woocommerce' === $domain && isset( $translations[ $text ] ) ? $translations[ $text ] : $translation;
+ },
+ 10,
+ 3
+ );
+ }
+
+ /**
+ * Use the given address format for a country.
+ *
+ * @param string $country Country code.
+ * @param string $format Address format.
+ */
+ private function use_address_format( $country, $format ): void {
+ add_filter(
+ 'woocommerce_localisation_address_formats',
+ function ( $formats ) use ( $country, $format ) {
+ $formats[ $country ] = $format;
+ return $formats;
+ }
+ );
+ }
+
/**
* Register the geographical data filters used by locale tests.
*/