Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Font Faces endpoint: prevent creating font faces with duplicate settings #57903

Merged
merged 7 commits into from
Jan 17, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public static function format_font_family( $font_family ) {
function ( $family ) {
$trimmed = trim( $family );
if ( ! empty( $trimmed ) && strpos( $trimmed, ' ' ) !== false && strpos( $trimmed, "'" ) === false && strpos( $trimmed, '"' ) === false ) {
return "'" . $trimmed . "'";
return '"' . $trimmed . '"';
}
return $trimmed;
},
Expand All @@ -107,4 +107,84 @@ function ( $family ) {

return $font_family;
}

/**
* Generates a slug from font face properties, e.g. `open sans;normal;400;100%;U+0-10FFFF`
*
* Used for comparison with other font faces in the same family, to prevent duplicates
* that would both match according the CSS font matching spec. Uses only simple case-insensitive
* matching for fontFamily and unicodeRange, so does not handle overlapping font-family lists or
* unicode ranges.
*
Copy link
Member

Choose a reason for hiding this comment

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

Maybe we could link to the CSS spec in the comments, so it's easy for folks to find if they want to see the spec details, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great idea! I've added a @link in the function's docblock.

* @since 6.5.0
*
* @link https://drafts.csswg.org/css-fonts/#font-style-matching
*
* @param array $settings {
* Font face settings.
*
* @type string $fontFamily Font family name.
* @type string $fontStyle Optional font style, defaults to 'normal'.
* @type string $fontWeight Optional font weight, defaults to 400.
* @type string $fontStretch Optional font stretch, defaults to '100%'.
* @type string $unicodeRange Optional unicode range, defaults to 'U+0-10FFFF'.
* }
* @return string Font face slug.
*/
public static function get_font_face_slug( $settings ) {
$settings = wp_parse_args(
$settings,
array(
'fontFamily' => '',
'fontStyle' => 'normal',
'fontWeight' => '400',
'fontStretch' => '100%',
'unicodeRange' => 'U+0-10FFFF',
)
);

// Convert all values to lowercase for comparison.
// Font family names may use multibyte characters.
$font_family = mb_strtolower( $settings['fontFamily'] );
$font_style = strtolower( $settings['fontStyle'] );
$font_weight = strtolower( $settings['fontWeight'] );
$font_stretch = strtolower( $settings['fontStretch'] );
$unicode_range = strtoupper( $settings['unicodeRange'] );

// Convert weight keywords to numeric strings.
$font_weight = str_replace( 'normal', '400', $font_weight );
$font_weight = str_replace( 'bold', '700', $font_weight );

// Convert stretch keywords to numeric strings.
$font_stretch_map = array(
'ultra-condensed' => '50%',
'extra-condensed' => '62.5%',
'condensed' => '75%',
'semi-condensed' => '87.5%',
'normal' => '100%',
'semi-expanded' => '112.5%',
'expanded' => '125%',
'extra-expanded' => '150%',
'untra-expanded' => '200%',
);
$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_map(
function ( $elem ) {
// Remove quotes to normalize font-family names, 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).
// 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 join( ';', $slug_elements );
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,22 @@ public function create_item( $request ) {
$settings = $request->get_param( 'font_face_settings' );
$file_params = $request->get_file_params();

// Check that the necessary font face properties are unique.
$existing_font_face = get_posts(
array(
'post_type' => $this->post_type,
'posts_per_page' => 1,
'title' => WP_Font_Family_Utils::get_font_face_slug( $settings ),
)
);
if ( ! empty( $existing_font_face ) ) {
return new WP_Error(
'rest_duplicate_font_face',
__( 'A font face matching those settings already exists.', 'gutenberg' ),
array( 'status' => 400 )
);
}

// Move the uploaded font asset from the temp folder to the fonts directory.
if ( ! function_exists( 'wp_handle_upload' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
Expand Down Expand Up @@ -648,11 +664,15 @@ protected function prepare_item_for_database( $request ) {
// Settings have already been decoded by ::sanitize_font_face_settings().
$settings = $request->get_param( 'font_face_settings' );

// Store this "slug" as the post_title rather than post_name, since it uses the fontFamily setting,
// which may contain multibyte characters.
$title = WP_Font_Family_Utils::get_font_face_slug( $settings );

$prepared_post->post_type = $this->post_type;
$prepared_post->post_parent = $request['font_family_id'];
$prepared_post->post_status = 'publish';
$prepared_post->post_title = $settings['fontFamily'];
$prepared_post->post_name = sanitize_title( $settings['fontFamily'] );
$prepared_post->post_title = $title;
$prepared_post->post_name = sanitize_title( $title );
$prepared_post->post_content = wp_json_encode( $settings );

return $prepared_post;
Expand Down Expand Up @@ -751,10 +771,10 @@ protected function get_settings_from_post( $post ) {
// Provide required, empty settings if needed.
if ( null === $settings ) {
$settings = array(
'src' => array(),
'fontFamily' => '',
'src' => array(),
);
}
$settings['fontFamily'] = $post->post_title ?? '';

// Only return the properties defined in the schema.
return array_intersect_key( $settings, $properties );
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,19 @@ public function data_should_format_font_family() {
return array(
'data_families_with_spaces_and_numbers' => array(
'font_family' => 'Rock 3D , Open Sans,serif',
'expected' => "'Rock 3D', 'Open Sans', serif",
'expected' => '"Rock 3D", "Open Sans", serif',
),
'data_single_font_family' => array(
'font_family' => 'Rock 3D',
'expected' => "'Rock 3D'",
'expected' => '"Rock 3D"',
),
'data_no_spaces' => array(
'font_family' => 'Rock3D',
'expected' => 'Rock3D',
),
'data_many_spaces_and_existing_quotes' => array(
'font_family' => 'Rock 3D serif, serif,sans-serif, "Open Sans"',
'expected' => "'Rock 3D serif', serif, sans-serif, \"Open Sans\"",
'expected' => '"Rock 3D serif", serif, sans-serif, "Open Sans"',
),
'data_empty_family' => array(
'font_family' => ' ',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php
/**
* Test WP_Font_Family_Utils::get_font_face_slug().
*
* @package WordPress
* @subpackage Font Library
* *
* @covers WP_Font_Family_Utils::get_font_face_slug
*/
class Tests_Fonts_WpFontsFamilyUtils_GetFontFamilySlug extends WP_UnitTestCase {
/**
* @dataProvider data_get_font_face_slug_normalizes_values
*/
public function test_get_font_face_slug_normalizes_values( $settings, $expected_slug ) {
$slug = WP_Font_Family_Utils::get_font_face_slug( $settings );

$this->assertSame( $expected_slug, $slug );
}

public function data_get_font_face_slug_normalizes_values() {
return array(
'Sets defaults' => array(
'settings' => array(
'fontFamily' => 'Open Sans',
),
'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF',
),
'Converts normal weight to 400' => array(
'settings' => array(
'fontFamily' => 'Open Sans',
'fontWeight' => 'normal',
),
'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF',
),
'Converts bold weight to 700' => array(
'settings' => array(
'fontFamily' => 'Open Sans',
'fontWeight' => 'bold',
),
'expected_slug' => 'open sans;normal;700;100%;U+0-10FFFF',
),
'Converts normal font-stretch to 100%' => array(
'settings' => array(
'fontFamily' => 'Open Sans',
'fontStretch' => 'normal',
),
'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF',
),
'Removes double quotes from fontFamilies' => array(
'settings' => array(
'fontFamily' => '"Open Sans"',
),
'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF',
),
'Removes single quotes from fontFamilies' => array(
'settings' => array(
'fontFamily' => "'Open Sans'",
),
'expected_slug' => 'open sans;normal;400;100%;U+0-10FFFF',
),
'Removes spaces between comma separated font families' => array(
'settings' => array(
'fontFamily' => 'Open Sans, serif',
),
'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF',
),
'Removes tabs between comma separated font families' => array(
'settings' => array(
'fontFamily' => "Open Sans,\tserif",
),
'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF',
),
'Removes new lines between comma separated font families' => array(
'settings' => array(
'fontFamily' => "Open Sans,\nserif",
),
'expected_slug' => 'open sans,serif;normal;400;100%;U+0-10FFFF',
),
);
}
}
Loading
Loading