Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
638 changes: 638 additions & 0 deletions src/wp-includes/fonts/class-wp-css-font-family.php

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/wp-includes/fonts/class-wp-font-collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ private static function get_sanitization_schema() {
'preview' => 'sanitize_url',
'fontFace' => array(
array(
'fontFamily' => 'sanitize_text_field',
'fontFamily' => 'WP_Font_Utils::sanitize_font_family',
'fontStyle' => 'sanitize_text_field',
'fontWeight' => 'sanitize_text_field',
'src' => static function ( $value ) {
Expand Down
23 changes: 14 additions & 9 deletions src/wp-includes/fonts/class-wp-font-face-resolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ private static function parse_settings( array $settings ) {
continue;
}

$font_family_name = self::maybe_parse_name_from_comma_separated_list( $definition['fontFamily'] );
$font_family_name = self::parse_font_family_descriptor( $definition['fontFamily'] );

// Skip if no font family is defined.
if ( empty( $font_family_name ) ) {
Expand All @@ -107,22 +107,27 @@ private static function parse_settings( array $settings ) {
}

/**
* Parse font-family name from comma-separated lists.
* Parses the `@font-face` font-family descriptor from a theme font family value.
*
* If the given `fontFamily` is a comma-separated lists (example: "Inter, sans-serif" ),
* parse and return the fist font from the list.
* If the given `fontFamily` is a list (example: "Inter, sans-serif"), the
* method selects the first family of the list. It returns the name as a
* quoted CSS string, so that the name keeps every character that it needs.
*
* @since 6.4.0
* @since 7.2.0 Uses {@see WP_CSS_Font_Family} and returns a quoted CSS string.
*
* @param string $font_family Font family `fontFamily' to parse.
* @return string Font-family name.
* @return string The font-family descriptor as a quoted CSS string, or an
* empty string if the value is invalid.
*/
private static function maybe_parse_name_from_comma_separated_list( $font_family ) {
if ( str_contains( $font_family, ',' ) ) {
$font_family = explode( ',', $font_family )[0];
private static function parse_font_family_descriptor( $font_family ) {
$name = WP_CSS_Font_Family::parse_descriptor_name( $font_family );

if ( null === $name || '' === $name ) {
return '';
}

return trim( $font_family, "\"'" );
return WP_CSS_Font_Family::serialize_name( $name );
}

/**
Expand Down
38 changes: 24 additions & 14 deletions src/wp-includes/fonts/class-wp-font-face.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* Font Face generates and prints `@font-face` styles for given fonts.
*
* @since 6.4.0
* @since 7.2.0 Writes the font-family descriptor as a quoted CSS string.
*/
class WP_Font_Face {

Expand Down Expand Up @@ -82,8 +83,9 @@ public function generate_and_print( array $fonts ) {

/*
* The font-face CSS is contained within <style> tags and can only be interpreted
* as CSS in the browser. Using wp_strip_all_tags() is sufficient escaping
* to avoid malicious attempts to close </style> and open a <script>.
* as CSS in the browser. The font-family descriptor escapes `<` as a CSS escape,
* so a font name cannot close the style element. wp_strip_all_tags() removes any
* remaining markup from the other descriptors.
*/
$css = wp_strip_all_tags( $css );

Expand Down Expand Up @@ -146,6 +148,25 @@ private function validate_font_face_declarations( array $font_face ) {
return false;
}

/*
* Read the font-family descriptor and keep the decoded name. The value
* can be CSS, such as `"ACME, Sans"`, or a plain name, such as
* `O'Reilly Sans`. The serializer writes it back as a quoted CSS string.
*/
$font_family_name = WP_CSS_Font_Family::parse_descriptor_name( $font_face['font-family'] );

if ( null === $font_family_name || '' === $font_family_name ) {
// @todo replace with `wp_trigger_error()`.
_doing_it_wrong(
__METHOD__,
__( 'Font font-family must be a valid CSS font family value or a plain font name.' ),
'7.2.0'
);
return false;
}

$font_face['font-family'] = WP_CSS_Font_Family::serialize_name( $font_family_name );

// Make sure that local fonts have 'src' defined.
if ( empty( $font_face['src'] ) || ( ! is_string( $font_face['src'] ) && ! is_array( $font_face['src'] ) ) ) {
// @todo replace with `wp_trigger_error()`.
Expand Down Expand Up @@ -307,18 +328,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'] . '"';
}

// The font-family is already a quoted CSS string. See ::validate_font_face_declarations().
foreach ( $font_face as $key => $value ) {
// Compile the "src" parameter.
if ( 'src' === $key ) {
Expand Down
127 changes: 78 additions & 49 deletions src/wp-includes/fonts/class-wp-font-utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,63 +18,41 @@
* @access private
*/
class WP_Font_Utils {
/**
* Adds surrounding quotes to font family names that contain special characters.
*
* It follows the recommendations from the CSS Fonts Module Level 4.
* @link https://www.w3.org/TR/css-fonts-4/#font-family-prop
*
* @since 6.5.0
*
* @param string $item A font family name.
* @return string The font family name with surrounding quotes, if necessary.
*/
private static function maybe_add_quotes( $item ) {
// Matches strings that are not exclusively alphabetic characters or hyphens, and do not exactly follow the pattern generic(alphabetic characters or hyphens).
$regex = '/^(?!generic\([a-zA-Z\-]+\)$)(?!^[a-zA-Z\-]+$).+/';
$item = trim( $item );
if ( preg_match( $regex, $item ) ) {
$item = trim( $item, "\"'" );
return '"' . $item . '"';
}
return $item;
}

/**
* Sanitizes and formats font family names.
*
* - Applies `sanitize_text_field`.
* - Adds surrounding quotes to names containing any characters that are not alphabetic or dashes.
* The method reads the value with the CSS `font-family` grammar and writes
* it back in a canonical form. It writes each named family as a quoted CSS
* string and keeps each generic family as a keyword. The decoded name does
* not change, so a name can contain a comma, an apostrophe, a quotation
* mark, or a CSS escape.
*
* For compatibility, the method also accepts a plain font name that is not
* valid CSS, such as `O'Reilly Sans`. It rejects a value that contains CSS
* syntax outside a quoted name, such as `"A"; color:red`.
*
* It follows the recommendations from the CSS Fonts Module Level 4.
* @link https://www.w3.org/TR/css-fonts-4/#font-family-prop
*
* @since 6.5.0
* @since 7.2.0 Uses {@see WP_CSS_Font_Family} to keep the font name. Names are
* always quoted, and an invalid value returns an empty string.
* @access private
*
* @see sanitize_text_field()
* @see WP_CSS_Font_Family::parse_list_with_plain_names()
*
* @param string $font_family Font family name(s), comma-separated.
* @return string Sanitized and formatted font family name(s).
* @return string Sanitized and formatted font family name(s), or an empty
* string if the value is invalid.
*/
public static function sanitize_font_family( $font_family ) {
if ( ! $font_family ) {
$entries = WP_CSS_Font_Family::parse_list_with_plain_names( $font_family );

if ( null === $entries ) {
return '';
}

$output = sanitize_text_field( $font_family );
$formatted_items = array();
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 );
}
return self::maybe_add_quotes( $output );
return WP_CSS_Font_Family::serialize_list( $entries );
}

/**
Expand All @@ -85,7 +63,11 @@ public static function sanitize_font_family( $font_family ) {
* matching for fontFamily and unicodeRange, so does not handle overlapping font-family lists or
* unicode ranges.
*
* The font family part uses the decoded font names, so two values that
* write the same name with different CSS escapes produce the same slug.
*
* @since 6.5.0
* @since 7.2.0 Compares decoded font names instead of raw CSS text.
* @access private
*
* @link https://drafts.csswg.org/css-fonts/#font-style-matching
Expand All @@ -102,18 +84,19 @@ public static function sanitize_font_family( $font_family ) {
* @return string Font face slug.
*/
public static function get_font_face_slug( $settings ) {
$defaults = array(
$defaults = array(
'fontFamily' => '',
'fontStyle' => 'normal',
'fontWeight' => '400',
'fontStretch' => '100%',
'unicodeRange' => 'U+0-10FFFF',
);
$settings = wp_parse_args( $settings, $defaults );
$settings = wp_parse_args( $settings, $defaults );
$font_family = self::get_font_family_comparison_key( $settings['fontFamily'] );
if ( function_exists( 'mb_strtolower' ) ) {
$font_family = mb_strtolower( $settings['fontFamily'] );
$font_family = mb_strtolower( $font_family );
} else {
$font_family = strtolower( $settings['fontFamily'] );
$font_family = strtolower( $font_family );
}
$font_style = strtolower( $settings['fontStyle'] );
$font_weight = strtolower( $settings['fontWeight'] );
Expand All @@ -137,23 +120,69 @@ public static function get_font_face_slug( $settings ) {
);
$font_stretch = str_replace( array_keys( $font_stretch_map ), array_values( $font_stretch_map ), $font_stretch );

$slug_elements = array( $font_family, $font_style, $font_weight, $font_stretch, $unicode_range );
$slug_elements = array( $font_style, $font_weight, $font_stretch, $unicode_range );

$slug_elements = array_map(
function ( $elem ) {
// Remove quotes to normalize font-family names, and ';' to use as a separator.
// Remove quotes to normalize the values, and ';' to use as a separator.
$elem = trim( str_replace( array( '"', "'", ';' ), '', $elem ) );

// Normalize comma separated lists by removing whitespace in between items,
// but keep whitespace within items (e.g. "Open Sans" and "OpenSans" are different fonts).
// Normalize comma separated lists by removing whitespace in between items.
// CSS spec for whitespace includes: U+000A LINE FEED, U+0009 CHARACTER TABULATION, or U+0020 SPACE,
// which by default are all matched by \s in PHP.
return preg_replace( '/,\s+/', ',', $elem );
},
$slug_elements
);

return sanitize_text_field( implode( ';', $slug_elements ) );
// The font family part keeps its own characters, so add it after the map above.
array_unshift( $slug_elements, $font_family );

return implode( ';', $slug_elements );
}

/**
* Builds the font family part of a font face slug.
*
* The method returns the decoded font names, separated by commas. Each
* name replaces a small set of characters with a percent sequence:
*
* - `;` and `,` cannot change the field boundaries of the slug.
* - `&`, `<`, and `>` cannot change when KSES filters the `post_title` of
* the font face post for a user without the `unfiltered_html` capability.
* - `\` cannot disappear when {@see WP_Query} removes slashes from its
* `title` query parameter.
* - `%` keeps the replacement reversible, so that two different names
* cannot produce one key.
*
* If the value is not a font family value that the parser accepts, the
* method falls back to the text normalization of WordPress 6.5.0, so that
* the slug of an existing record does not change.
*
* @since 7.2.0
*
* @param string $font_family Font family value.
* @return string The font family comparison key.
*/
private static function get_font_family_comparison_key( $font_family ) {
$entries = WP_CSS_Font_Family::parse_list_with_plain_names( $font_family );

if ( null === $entries ) {
// Keep the WordPress 6.5.0 behavior for a value that the parser rejects.
$key = trim( str_replace( array( '"', "'", ';' ), '', (string) $font_family ) );
return preg_replace( '/,\s+/', ',', $key );
}

// Replace '%' first, so that the replacement stays reversible.
$search = array( '%', '\\', ';', ',', '&', '<', '>' );
$replace = array( '%25', '%5c', '%3b', '%2c', '%26', '%3c', '%3e' );

$keys = array();
foreach ( $entries as $entry ) {
$keys[] = str_replace( $search, $replace, $entry['value'] );
}

return implode( ',', $keys );
}

/**
Expand Down
Loading
Loading