Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
201d1cf
fix:build-font-face-css single inverted comma in name issue
iamsandeepdahiya Jun 15, 2025
08ad65b
removed whitespace from the end
iamsandeepdahiya Jun 15, 2025
f09ea75
using WP_Font_Utils::maybe_add_quotes() after exposing it public
iamsandeepdahiya Jun 16, 2025
9f29537
renamed to normalize_quoted_font_family_name and added more edge cases
iamsandeepdahiya Jun 17, 2025
b88e32c
"Reverting to previous minor fix"
iamsandeepdahiya Jun 17, 2025
044e261
removed whitespace and renamed to normalize_css_font_family_name
iamsandeepdahiya Jun 17, 2025
927af12
Merge remote-tracking branch 'iamsandeepdahiya/build-font-face-css' i…
sirreal Sep 11, 2025
58fffca
Revert normalize font face changes
sirreal Sep 18, 2025
98fd99f
Add normalize_css_font_face utility
sirreal Sep 18, 2025
93c7284
Use normalize_css_font_face
sirreal Sep 18, 2025
f99a644
Update tests for quoted font family name
sirreal Sep 18, 2025
a74fd98
Add specific tests for ' character
sirreal Sep 18, 2025
245075f
Use font_family name
sirreal Sep 19, 2025
b4fbd16
Add normalization-specific tests
sirreal Sep 19, 2025
31a0dee
Escape `<` too for good measure
sirreal Sep 19, 2025
d2d2f1c
Remove normalization of quoted strings
sirreal Sep 19, 2025
a61fa4e
Fix typos, language, comment alignment
sirreal Sep 19, 2025
96a3293
Improve doint it wrong message
sirreal Sep 19, 2025
95a9d84
Document unusual escapes in font family name
sirreal Nov 4, 2025
93fbf89
Merge branch 'trunk' into fix/font-face-name-normalization
sirreal Nov 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 64 additions & 11 deletions src/wp-includes/fonts/class-wp-font-face.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not 100% satisfied with this. A broken family name could be used, like """, which would result in broken CSS output. The existing implementation has the same issue. This is likely good enough for anything but the most malicious font name.

The font family name """ could be safely used with this implementation as '"""' or preferably something like "\22\22\22".

One improvement here is that the string must start and end with a matching quote character "…" or '…' in order to be treated as a quoted string. This allows fonts to contain those characters without issue and they'll be normalized properly.


It would be nice if the system knew that plain strings were provided and all quoting and normalization of the font family name were handled by the system. I'm not sure that's possible while maintaining backwards compatibility.

}

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 ',
Copy link
Contributor

@adamziel adamziel Nov 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this one necessary? Not that it's wrong, but we're producing a quoted value anyway. It shouldn't derail any syntax parsing and, within a string, it's seen as a comma either way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When printing CSS, the font system checks for , and does a split/join in an effort to normalize the font family names:

if ( str_contains( $output, ',' ) ) {
$items = explode( ',', $output );
foreach ( $items as $item ) {
$formatted_item = self::maybe_add_quotes( $item );
if ( ! empty( $formatted_item ) ) {
$formatted_items[] = $formatted_item;
}
}
return implode( ', ', $formatted_items );
}

It seemed preferable to escape , in the single font family name here to try to avoid being mangled later.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah so it's a workaround for the deficiencies of another part of the system. Could we go the other way around and parse the value instead of exploding by ,? The CSS Processor has some code that could help with this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Core doesn't have a CSS parser, does it?

This Unicode escaping is at worst harmless and at best better than requiring full blown CSS parsing (even if that would be the most correct thing to do later).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't today, but it could. CSS Processor is just a tokenizer, not a full-blown parser. Or you might want to reuse just the part of it that parses strings, just to avoid the blanket explode() call.

'"' => '\\22 ',
'<' => '\\3C ',
)
) . '"';
}

/**
* Compiles the `src` into valid CSS.
*
Expand Down
101 changes: 90 additions & 11 deletions tests/phpunit/tests/fonts/font-face/wp-font-face-tests-dataset.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -34,11 +34,11 @@ public function data_should_print_given_fonts() {
),
),
'expected' => <<<CSS
@font-face{font-family:Inter;font-style:normal;font-weight:200;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Inter";font-style:normal;font-weight:200;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf') format('truetype');font-stretch:normal;}
CSS
,
),
'multiple truetype format fonts' => array(
'multiple truetype format fonts' => array(
'fonts' => array(
'Inter' =>
array(
Expand All @@ -65,12 +65,12 @@ public function data_should_print_given_fonts() {
),
),
'expected' => <<<CSS
@font-face{font-family:Inter;font-style:normal;font-weight:200;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:Inter;font-style:italic;font-weight:900;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt-Italic,wght.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Inter";font-style:normal;font-weight:200;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt,wght.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Inter";font-style:italic;font-weight:900;font-display:fallback;src:url('https://example.org/assets/fonts/inter/Inter-VariableFont_slnt-Italic,wght.ttf') format('truetype');font-stretch:normal;}
CSS
,
),
'single woff2 format font' => array(
'single woff2 format font' => array(
'fonts' => array(
'DM Sans' =>
array(
Expand All @@ -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(
Expand Down Expand Up @@ -231,10 +231,89 @@ public function data_should_print_given_fonts() {
),
),
'expected' => <<<CSS
@font-face{font-family:Piazzolla;font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/piazzolla400.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:Piazzolla;font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/piazzolla500.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:Lobster;font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/lobster400.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:Lobster;font-style:normal;font-weight:500;font-display:fallback;src:url('https://example.org/fonts/lobster500.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Piazzolla";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/piazzolla400.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Piazzolla";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/piazzolla500.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Lobster";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/lobster400.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Lobster";font-style:normal;font-weight:500;font-display:fallback;src:url('https://example.org/fonts/lobster500.ttf') format('truetype');font-stretch:normal;}
CSS
,
),
),

"Fonts with `'` character (ticket #63568)" => 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' => <<<CSS
@font-face{font-family:"O'Reilly Sans";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/assets/fonts/oreilly-sans/oreilly-sans.woff2') format('woff2');font-stretch:normal;}
@font-face{font-family:"Suisse BP Int'l";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/assets/fonts/suisse-bp-intl/suisse-bp-intl.woff2') format('woff2');font-stretch:normal;}
CSS
,
'indexed array as input' => 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' => <<<CSS
@font-face{font-family:"Piazzolla";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/piazzolla400.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Piazzolla";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/piazzolla500.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Lobster";font-style:normal;font-weight:400;font-display:fallback;src:url('https://example.org/fonts/lobster400.ttf') format('truetype');font-stretch:normal;}
@font-face{font-family:"Lobster";font-style:normal;font-weight:500;font-display:fallback;src:url('https://example.org/fonts/lobster500.ttf') format('truetype');font-stretch:normal;}
CSS
,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,55 @@ public function test_should_generate_and_print_given_fonts( array $fonts, $expec
$this->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\nLT<Oh😵My!", '"BS\\5C Quot\\22 Apos\'Semi;Comma\\2C Newline\\A LT\\3C Oh😵My!"' ),
);
}

/**
* Ensure already-quoted font family names emit doing it wrong notice and skip normalization.
*
* @expectedIncorrectUsage WP_Font_Face::normalize_css_font_family
*
* @ticket 63568
*
* @dataProvider data_quoted_font_family_normalization
*/
public function test_quoted_font_family_doing_it_wrong_no_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_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( '"""', '"""' ),
);
}
}
Loading