diff --git a/src/wp-includes/fonts/class-wp-font-face.php b/src/wp-includes/fonts/class-wp-font-face.php index 07cd3d6de9002..eeee3b235ef03 100644 --- a/src/wp-includes/fonts/class-wp-font-face.php +++ b/src/wp-includes/fonts/class-wp-font-face.php @@ -358,17 +358,7 @@ private function order_src( array $font_face ) { private function build_font_face_css( array $font_face ) { $css = ''; - /* - * Wrap font-family in quotes if it contains spaces - * and is not already wrapped in quotes. - */ - if ( - str_contains( $font_face['font-family'], ' ' ) && - ! str_contains( $font_face['font-family'], '"' ) && - ! str_contains( $font_face['font-family'], "'" ) - ) { - $font_face['font-family'] = '"' . $font_face['font-family'] . '"'; - } + $font_face['font-family'] = $this->normalize_css_font_family( $font_face['font-family'] ); foreach ( $font_face as $key => $value ) { // Compile the "src" parameter. @@ -389,6 +379,69 @@ private function build_font_face_css( array $font_face ) { return $css; } + /** + * Normalizes a font-face name for use in CSS. + * + * Add quotes to the font-face name and escape problematic characters. + * + * @see https://www.w3.org/TR/css-fonts-4/#font-family-desc + * + * @since 6.9.0 + * + * @param string $font_family The font-face name to normalize. + * @return string The normalized font-face name. + */ + protected function normalize_css_font_family( string $font_family ): string { + $font_family = trim( $font_family, " \t\r\f\n" ); + + if ( + strlen( $font_family ) > 1 && + ( '"' === $font_family[0] && '"' === $font_family[ strlen( $font_family ) - 1 ] ) || + ( "'" === $font_family[0] && "'" === $font_family[ strlen( $font_family ) - 1 ] ) + ) { + _doing_it_wrong( + __METHOD__, + __( 'Font family should not be wrapped in quotes; they will be added automatically.' ), + '6.9.0' + ); + return $font_family; + } + + return '"' . strtr( + $font_family, + array( + /* + * Normalize preprocessed whitespace. + * https://www.w3.org/TR/css-syntax-3/#input-preprocessing + */ + "\r" => '\\A ', + "\f" => '\\A ', + "\r\n" => '\\A ', + + /* + * CSS Unicode escaping for problematic characters. + * https://www.w3.org/TR/css-syntax-3/#escaping + * + * These characters are not required by CSS but may be problematic in WordPress: + * + * - "<" is replaced to prevent issues with KSES and other sanitization when + * printing CSS later. + * - "," is replaced to prevent issues where multiple font family names may be + * split, sanitized, and joined on the `,` character (regardless of quoting + * or escaping). + * + * Note that the Unicode escape sequences are used rather than backslash-escaping. + * This also helps to prevent issues with problematic characters. + */ + "\n" => '\\A ', + '\\' => '\\5C ', + ',' => '\\2C ', + '"' => '\\22 ', + '<' => '\\3C ', + ) + ) . '"'; + } + /** * Compiles the `src` into valid CSS. * diff --git a/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php b/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php index d410acb7c4124..2ab3765e48f51 100644 --- a/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php +++ b/tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php @@ -17,7 +17,7 @@ trait WP_Font_Face_Tests_Datasets { */ public function data_should_print_given_fonts() { return array( - 'single truetype format font' => array( + 'single truetype format font' => array( 'fonts' => array( 'Inter' => array( @@ -34,11 +34,11 @@ public function data_should_print_given_fonts() { ), ), 'expected' => << array( + 'multiple truetype format fonts' => array( 'fonts' => array( 'Inter' => array( @@ -65,12 +65,12 @@ public function data_should_print_given_fonts() { ), ), 'expected' => << array( + 'single woff2 format font' => array( 'fonts' => array( 'DM Sans' => array( @@ -91,7 +91,7 @@ public function data_should_print_given_fonts() { CSS , ), - 'multiple woff2 format fonts' => array( + 'multiple woff2 format fonts' => array( 'fonts' => array( 'DM Sans' => array( @@ -231,10 +231,89 @@ public function data_should_print_given_fonts() { ), ), 'expected' => << array( + 'fonts' => array( + "O'Reilly Sans" => + array( + array( + 'src' => + array( + 'https://example.org/assets/fonts/oreilly-sans/oreilly-sans.woff2', + ), + 'font-family' => "O'Reilly Sans", + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '400', + ), + ), + "Suisse BP Int'l" => + array( + array( + 'src' => + array( + 'https://example.org/assets/fonts/suisse-bp-intl/suisse-bp-intl.woff2', + ), + 'font-family' => "Suisse BP Int'l", + 'font-stretch' => 'normal', + 'font-style' => 'normal', + 'font-weight' => '400', + ), + ), + ), + 'expected' => << array( + 'fonts' => array( + array( + array( + 'font-family' => 'Piazzolla', + 'src' => array( 'https://example.org/fonts/piazzolla400.ttf' ), + 'font-style' => 'normal', + 'font-weight' => '400', + 'font-stretch' => 'normal', + ), + array( + 'font-family' => 'Piazzolla', + 'src' => array( 'https://example.org/fonts/piazzolla500.ttf' ), + 'font-style' => 'normal', + 'font-weight' => '400', + 'font-stretch' => 'normal', + ), + ), + array( + array( + 'font-family' => 'Lobster', + 'src' => array( 'https://example.org/fonts/lobster400.ttf' ), + 'font-style' => 'normal', + 'font-weight' => '400', + 'font-stretch' => 'normal', + ), + array( + 'font-family' => 'Lobster', + 'src' => array( 'https://example.org/fonts/lobster500.ttf' ), + 'font-style' => 'normal', + 'font-weight' => '500', + 'font-stretch' => 'normal', + ), + ), + ), + 'expected' => <<expectOutputString( $expected_output ); $font_face->generate_and_print( $fonts ); } + + + /** + * @ticket 63568 + * + * @dataProvider data_font_family_normalization + */ + public function test_font_family_css_normalization( string $font_name, string $expected ) { + $normalizer = new class() extends WP_Font_Face { + public function test_normalization( string $font_name ): string { + return $this->normalize_css_font_family( $font_name ); + } + }; + $this->assertSame( $expected, $normalizer->test_normalization( $font_name ) ); + } + + public static function data_font_family_normalization() { + return array( + 'Typical name' => array( 'A font name', '"A font name"' ), + 'Generic collision' => array( 'serif', '"serif"' ), + 'Trims whitespace' => array( ' A font name ', '"A font name"' ), + 'Name with \' character' => array( 'O\'Reilly Sans', '"O\'Reilly Sans"' ), + 'Unrealistically tricky' => array( "BS\\Quot\"Apos'Semi;Comma,Newline\nLTnormalize_css_font_family( $font_name ); + } + }; + $this->assertSame( $expected, $normalizer->test_normalization( $font_name ) ); + } + + public static function data_quoted_font_family_normalization() { + return array( + "Quoted with '" => array( "'A font name'", "'A font name'" ), + 'Quoted with "' => array( '"A font name"', '"A font name"' ), + 'Quoted is not escaped' => array( '"""', '"""' ), + ); + } }