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&ocirc;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&ocirc;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&ocirc;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&lt;b&gt;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&lt;B&gt;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.
 	 */