@@ -246,6 +246,43 @@ public static function data_semicolonless_attribute_behaviors(): array {
246246 );
247247 }
248248
249+ /**
250+ * Ensures proper decoding of ambiguous ampersands.
251+ *
252+ * @ticket 61072
253+ *
254+ * @dataProvider data_ambiguous_ampersands
255+ *
256+ * @param string $context 'attribute' or 'data'.
257+ * @param string $raw_text Raw text.
258+ * @param string $expected Expected decoded string.
259+ */
260+ public function test_decodes_ambiguous_ampersands ( string $ context , string $ raw_text , string $ expected ): void {
261+ $ this ->assertSame (
262+ $ expected ,
263+ WP_HTML_Decoder::decode ( $ context , $ raw_text ),
264+ "Failed handling ambiguous ampersand in context ' {$ context }' for ' {$ raw_text }'. "
265+ );
266+ }
267+
268+ /**
269+ * Data provider for ambiguous ampersands.
270+ *
271+ * @return array<string, array{string, string, string}>
272+ */
273+ public static function data_ambiguous_ampersands (): array {
274+ return array (
275+ 'Semicolonless reference (data) ' => array ( 'data ' , '& ' , '& ' ),
276+ 'Semicolonless reference (attr) ' => array ( 'attribute ' , '& ' , '& ' ),
277+ 'Semicolonless followed by equals (data) ' => array ( 'data ' , '¬= ' , '¬= ' ),
278+ 'Semicolonless followed by letter (data) ' => array ( 'data ' , '¬it ' , '¬it ' ),
279+ 'With semicolon (data) ' => array ( 'data ' , '¬ ' , '¬ ' ),
280+ 'With semicolon (attr) ' => array ( 'attribute ' , '¬ ' , '¬ ' ),
281+ 'Semicolonless followed by space (data) ' => array ( 'data ' , '¬ ' , '¬ ' ),
282+ 'Semicolonless followed by space (attr) ' => array ( 'attribute ' , '¬ ' , '¬ ' ),
283+ );
284+ }
285+
249286 /**
250287 * Ensures ambiguous ampersand is recognized with trailing ASCII alphanumerics.
251288 *
@@ -517,4 +554,200 @@ public static function data_attributes_with_prefix_and_case_sensitive_match() {
517554 array ( 'http://wordpress.org ' , 'https ' , 'ascii-case-insensitive ' , false ),
518555 );
519556 }
557+
558+ /**
559+ * Ensures decoding of named character references in attributes.
560+ *
561+ * @ticket 61072
562+ *
563+ * @dataProvider data_decode_attribute_named_character_references
564+ *
565+ * @param string $raw_text Raw attribute value containing named character reference.
566+ * @param string $expected Expected decoded string.
567+ */
568+ public function test_decode_attribute_decodes_named_character_references ( string $ raw_text , string $ expected ): void {
569+ $ this ->assertSame (
570+ $ expected ,
571+ WP_HTML_Decoder::decode_attribute ( $ raw_text ),
572+ "Failed decoding named character reference in attribute ' {$ raw_text }'. "
573+ );
574+ }
575+
576+ /**
577+ * Data provider for named character references in attributes.
578+ *
579+ * Beyond the main syntax characters, covers references which do and do not
580+ * decode without a trailing semicolon, as well as case-sensitive names.
581+ *
582+ * @return array<string, array{string, string}>
583+ */
584+ public static function data_decode_attribute_named_character_references (): array {
585+ return array (
586+ // The main syntax characters.
587+ 'Ampersand with semicolon ' => array ( '& ' , '& ' ),
588+ 'Ampersand without semicolon ' => array ( '& ' , '& ' ),
589+ 'Less-than with semicolon ' => array ( '< ' , '< ' ),
590+ 'Less-than without semicolon ' => array ( '< ' , '< ' ),
591+ 'Greater-than with semicolon ' => array ( '> ' , '> ' ),
592+ 'Greater-than without semicolon ' => array ( '> ' , '> ' ),
593+ 'Double quote with semicolon ' => array ( '" ' , '" ' ),
594+ 'Double quote without semicolon ' => array ( '" ' , '" ' ),
595+
596+ // Legacy references which decode without a semicolon.
597+ 'Copyright with semicolon ' => array ( '© ' , '© ' ),
598+ 'Copyright without semicolon ' => array ( '© ' , '© ' ),
599+ 'Uppercase copyright with semicolon ' => array ( '© ' , '© ' ),
600+ 'Uppercase copyright without semicolon ' => array ( '© ' , '© ' ),
601+
602+ // References which only decode with a semicolon.
603+ 'C with dot with semicolon ' => array ( 'ċ ' , 'ċ ' ),
604+ 'C with dot without semicolon ' => array ( '&cdot ' , '&cdot ' ),
605+ 'Uppercase C with dot with semicolon ' => array ( 'Ċ ' , 'Ċ ' ),
606+ 'Uppercase C with dot without semicolon ' => array ( '&Cdot ' , '&Cdot ' ),
607+ 'Backprime with semicolon ' => array ( '‵ ' , '‵ ' ),
608+ 'Backprime without semicolon ' => array ( '&backprime ' , '&backprime ' ),
609+
610+ // Names are case-sensitive: only the lowercase form exists.
611+ 'Non-existent uppercase Backprime ' => array ( '&Backprime; ' , '&Backprime; ' ),
612+ );
613+ }
614+
615+ /**
616+ * Ensures decoding of numeric character references across their syntactic variants.
617+ *
618+ * @ticket 61072
619+ *
620+ * @dataProvider data_numeric_character_references
621+ *
622+ * @param string $raw_text Raw numeric character reference.
623+ * @param string $expected Expected decoded string.
624+ */
625+ public function test_decodes_numeric_character_references ( string $ raw_text , string $ expected ): void {
626+ $ this ->assertSame (
627+ $ expected ,
628+ WP_HTML_Decoder::decode_text_node ( $ raw_text ),
629+ "Failed decoding numeric character reference in text node: ' {$ raw_text }'. "
630+ );
631+ $ this ->assertSame (
632+ $ expected ,
633+ WP_HTML_Decoder::decode_attribute ( $ raw_text ),
634+ "Failed decoding numeric character reference in attribute: ' {$ raw_text }'. "
635+ );
636+ }
637+
638+ /**
639+ * Data provider.
640+ *
641+ * Generates, for a representative set of code points, every combination of:
642+ * - decimal vs. hexadecimal syntax,
643+ * - `x` vs. `X` introducer and lower vs. upper case hexadecimal digits,
644+ * - count of leading zeros (which do not count toward the digit limit),
645+ * - semicolon present or missing.
646+ *
647+ * @return Generator<string, array{string, string}> Test cases.
648+ */
649+ public static function data_numeric_character_references (): Generator {
650+ $ code_points = array (
651+ 'ASCII ' => array ( 0x41 , 'A ' ),
652+ 'Two-byte UTF-8 ' => array ( 0xE9 , 'é ' ),
653+ 'Three-byte UTF-8 ' => array ( 0x2603 , '☃ ' ),
654+ 'Astral plane ' => array ( 0x1F600 , '😀 ' ),
655+ 'Maximum code point ' => array ( 0x10FFFF , "\u{10FFFF}" ),
656+
657+ // Surrogate halves and code points beyond U+10FFFF decode to U+FFFD.
658+ 'High surrogate start ' => array ( 0xD800 , "\u{FFFD}" ),
659+ 'High surrogate middle ' => array ( 0xDABC , "\u{FFFD}" ),
660+ 'Low surrogate end ' => array ( 0xDFFF , "\u{FFFD}" ),
661+ 'Out of range ' => array ( 0x110000 , "\u{FFFD}" ),
662+ );
663+
664+ foreach ( $ code_points as $ label => list ( $ code_point , $ expected ) ) {
665+ $ hex = dechex ( $ code_point );
666+ $ syntaxes = array (
667+ array ( '# ' , (string ) $ code_point ),
668+ array ( '#x ' , $ hex ),
669+ array ( '#x ' , strtoupper ( $ hex ) ),
670+ array ( '#X ' , $ hex ),
671+ array ( '#X ' , strtoupper ( $ hex ) ),
672+ );
673+
674+ // Hexadecimal digits without letters have no case variants.
675+ $ syntaxes = array_unique ( $ syntaxes , SORT_REGULAR );
676+
677+ foreach ( $ syntaxes as list ( $ introducer , $ digit_string ) ) {
678+ foreach ( array ( 0 , 1 , 10 ) as $ zero_count ) {
679+ foreach ( array ( '; ' , '' ) as $ terminator ) {
680+ $ raw_text = '& ' . $ introducer . str_repeat ( '0 ' , $ zero_count ) . $ digit_string . $ terminator ;
681+
682+ yield "{$ label }: {$ raw_text }" => array ( $ raw_text , $ expected );
683+ }
684+ }
685+ }
686+ }
687+ }
688+
689+ /**
690+ * Ensures that Windows-1252 mapped characters are properly decoded.
691+ *
692+ * @ticket 61072
693+ *
694+ * @dataProvider data_windows_1252_mapped_characters
695+ *
696+ * @param string $raw_text Raw numeric character reference.
697+ * @param string $expected Expected decoded character.
698+ */
699+ public function test_decodes_windows_1252_mapped_characters ( string $ raw_text , string $ expected ): void {
700+ $ this ->assertSame (
701+ $ expected ,
702+ WP_HTML_Decoder::decode_text_node ( $ raw_text ),
703+ "Failed decoding Windows-1252 character reference in text node: ' {$ raw_text }'. "
704+ );
705+ $ this ->assertSame (
706+ $ expected ,
707+ WP_HTML_Decoder::decode_attribute ( $ raw_text ),
708+ "Failed decoding Windows-1252 character reference in attribute: ' {$ raw_text }'. "
709+ );
710+ }
711+
712+ /**
713+ * Data provider for Windows-1252 mapped characters.
714+ *
715+ * @return array<string, array{string, string}>
716+ */
717+ public static function data_windows_1252_mapped_characters (): array {
718+ return array (
719+ 'Euro sign ' => array ( '€ ' , '€ ' ),
720+ 'Unmapped U+81 ' => array ( ' ' , "\u{81}" ),
721+ 'Single low-9 ' => array ( '‚ ' , '‚ ' ),
722+ 'F with hook ' => array ( 'ƒ ' , 'ƒ ' ),
723+ 'Double low-9 ' => array ( '„ ' , '„ ' ),
724+ 'Ellipsis ' => array ( '… ' , '… ' ),
725+ 'Dagger ' => array ( '† ' , '† ' ),
726+ 'Double dagger ' => array ( '‡ ' , '‡ ' ),
727+ 'Circumflex ' => array ( 'ˆ ' , 'ˆ ' ),
728+ 'Per mille ' => array ( '‰ ' , '‰ ' ),
729+ 'S with caron ' => array ( 'Š ' , 'Š ' ),
730+ 'Less single guil ' => array ( '‹ ' , '‹ ' ),
731+ 'OE ligature ' => array ( 'Œ ' , 'Œ ' ),
732+ 'Unmapped U+8D ' => array ( ' ' , "\u{8D}" ),
733+ 'Z with caron ' => array ( 'Ž ' , 'Ž ' ),
734+ 'Unmapped U+8F ' => array ( ' ' , "\u{8F}" ),
735+ 'Unmapped U+90 ' => array ( ' ' , "\u{90}" ),
736+ 'Left single quot ' => array ( '‘ ' , '‘ ' ),
737+ 'Right single quo ' => array ( '’ ' , '’ ' ),
738+ 'Left double quot ' => array ( '“ ' , '“ ' ),
739+ 'Right double quo ' => array ( '” ' , '” ' ),
740+ 'Bullet ' => array ( '• ' , '• ' ),
741+ 'En dash ' => array ( '– ' , '– ' ),
742+ 'Em dash ' => array ( '— ' , '— ' ),
743+ 'Small tilde ' => array ( '˜ ' , '˜ ' ),
744+ 'Trade mark ' => array ( '™ ' , '™ ' ),
745+ 's with caron ' => array ( 'š ' , 'š ' ),
746+ 'Right single gui ' => array ( '› ' , '› ' ),
747+ 'oe ligature ' => array ( 'œ ' , 'œ ' ),
748+ 'Unmapped U+9D ' => array ( ' ' , "\u{9D}" ),
749+ 'z with caron ' => array ( 'ž ' , 'ž ' ),
750+ 'Y with diaeresis ' => array ( 'Ÿ ' , 'Ÿ ' ),
751+ );
752+ }
520753}
0 commit comments