Skip to content

Fonts: Keep font names through CSS validation, storage, and output - #13610

Draft
matiasbenedetto wants to merge 16 commits into
WordPress:trunkfrom
matiasbenedetto:fix-font-chars
Draft

matiasbenedetto wants to merge 16 commits into
WordPress:trunkfrom
matiasbenedetto:fix-font-chars

Conversation

@matiasbenedetto

@matiasbenedetto matiasbenedetto commented Sep 18, 2026 •

Copy link
Copy Markdown

Core changes some font names when it treats CSS values as plain text. For example, O'Reilly Sans produces an invalid unquoted @font-face descriptor. This PR preserves the decoded name through font validation, storage, resolution, and CSS output.

Trac ticket: https://core.trac.wordpress.org/ticket/63568

-@font-face{font-family:O'Reilly Sans; ... }
+@font-face{font-family:"O'Reilly Sans"; ... }

The fix also preserves commas inside quoted names, significant spaces, CSS escapes, and punctuation. It keeps a named family such as "serif" distinct from the generic keyword serif.

Implementation. WP_Font_Utils gets a private CSS font-family parser and serializer. The class adds only two public methods, and both return plain values:

  • is_valid_css_font_family() returns true if the value is valid CSS. It requires the complete value to be a list of names and generic families, or one CSS-wide keyword.
  • get_font_face_family() returns the first family of the value as a quoted CSS string for an @font-face descriptor. It returns an empty string if the value names no font.

The private parser has two modes:

  • The strict mode requires valid CSS and consumes the complete value. It returns typed entries for named families, generic families, and reserved keywords.
  • The plain name mode reads each entry of the list as CSS. If an entry is not valid CSS, it reads the raw text up to the next comma as one plain name, as earlier WordPress versions did. This accepts O'Reilly Sans, O"Reilly Sans, Bodoni*, and Font (Display). A plain name can hold any text except control characters, and the serializer makes it inert. The parser ignores an empty entry, such as the entry after a trailing comma.

The private serializer keeps explicit quotes around each named family. An unquoted name stays unquoted if it uses one identifier of letters and hyphens. Each other name gets quotes. The serializer escapes quotes, backslashes, control characters, <, >, &, ;, and ,. It keeps the family order and the difference between names and generic keywords.

The array of parsed entries is not public, so it is not an API contract.

The parser uses no recursion. It accepts only kai, fangsong, khmer-mul, and nastaliq as generic() arguments. It validates the decoded argument before it emits CSS, so an escaped argument cannot introduce a second declaration.

These parts of core use the shared parser:

  • WP_Font_Utils::sanitize_font_family() and WP_Font_Utils::get_font_face_slug().
  • WP_Font_Face and WP_Font_Face_Resolver.
  • The fontFamily schema of WP_Font_Collection.
  • Both font REST controllers. A value with control characters returns a 400 error. The font faces controller also rejects the empty quoted name "". The controllers accept the name 0, which the empty check read as a false value.
  • safecss_filter_attr().

An invalid face value produces a _doing_it_wrong() notice and no face output.

KSES. safecss_filter_attr() still splits declarations at each semicolon. If the CSS font family grammar accepts a font-family value, the function skips the character checks that reject punctuation, such as a parenthesis or a backslash escape. Other values still use the current checks, and the safecss_filter_attr_allow_css filter remains in the path. The serializer writes a semicolon in a name as \3b , so the split at each semicolon keeps the name.

An earlier revision of this PR used a quote-aware declaration splitter. I removed it, because it read a quote inside an unquoted url() as the start of a string. A declaration after it, such as behavior: url(x.htc), then got past the allowlist.

Compatibility.

Input sanitize_font_family() output
Manrope Manrope
"Manrope" "Manrope"
"-webkit-body" "-webkit-body"
-webkit-body -webkit-body
-apple-system, BlinkMacSystemFont, sans-serif -apple-system, BlinkMacSystemFont, sans-serif
Open Sans, sans-serif "Open Sans", sans-serif
O'Reilly Sans "O'Reilly Sans"
"ACME, Sans", sans-serif "ACME\2c Sans", sans-serif
"serif", serif "serif", serif
Bodoni* "Bodoni*"
"A"; color:red "\"A\"\3b color:red" (one inert name)
A + U+0001 + B empty string
  • The serializer keeps explicit quotes around a named family. Thus "-webkit-body" selects a named font instead of a browser keyword. An unquoted name stays unquoted if it uses one identifier of letters and hyphens. Each other name gets quotes. The same rule preserves the difference between "-apple-system" and -apple-system.
  • The @font-face descriptor always uses quotes. Thus font-family:Manrope in the face output becomes font-family:"Manrope". The preset value stays Manrope.
  • The preset can contain "ACME\2c Sans", sans-serif, while its face descriptor contains only "ACME\2c Sans". The decoded name stays ACME, Sans.
  • The serializer escapes a comma, because some clients split a font family list at each comma and do not read quoted strings. For example, the Gutenberg Font Library preview function formatFontFamily() read "ACME, Sans" as the two names ACME and Sans, so the preview used a fallback font. The Gutenberg client function createCssString() already escapes a comma in the same way.
  • The serializer escapes <, >, and & so names survive HTML output and KSES post filters. Backslashes use hexadecimal escapes because wp_kses_no_null() can remove a literal backslash before zeros. Short hexadecimal escapes retain their terminator spaces.
  • get_font_face_slug() compares decoded names, so equivalent CSS escapes identify the same face. It keeps the WordPress 6.5 rule that removes quotation marks and apostrophes from a name. For example, O'Reilly Sans gives oreilly sans;normal;400;100%;U+0-10FFFF. A record from an earlier version can still have a different slug.
  • In the slug, the characters %, \, ;, ,, &, <, and > in a name become percent sequences. They cannot change the slug fields or the post_title. Ordinary names such as Open Sans retain their previous slug. If the parser rejects a value, the slug uses the WordPress 6.5 text normalization.
  • The duplicate check compares saved settings with the current slug rules, including when a title matches. Thus a title collision between the old and new slug formats does not reject a different font name. If the title check finds no matching settings, the controller reads face records in batches of 100. This adds database reads and catches duplicates across families and after the first batch.
  • This PR performs no bulk data migration.

Client dependency and limits. The upload client in core sends the raw name from the font file as fontFamily, for example Bodoni*. Core reads each entry as CSS first. If the entry is not valid CSS, core stores the raw text as one name. Thus most raw names work with the current client.

Some raw names are also valid CSS with a different meaning, and the server cannot tell them apart. CSS reads a comma as a list, /* */ as a comment, a backslash as an escape, serif or inherit as a keyword, and joins spaces between identifiers. These names need an upload client that sends a quoted CSS string, such as Gutenberg PR #76782. Core accepts valid CSS without the special escape scheme of that PR.

Display-name storage remains outside this fix. The family controller still applies sanitize_text_field() to the display name in post_title. A display name with markup can therefore lose that text, independently of the CSS identity.

Font name test guide

This list is the reference for all tests of font names in this PR. Each case has a number. The same number is in the file name of its test font. The PHPUnit test Tests_Fonts_FontFamilyDataPath::test_raw_name_gives_the_documented_result sends each raw name of the list through the REST API and checks the expected result, with the case number as the name of the data set. Cases 84 and 85 hold invalid UTF-8, which a JSON request cannot carry.

Test fonts. Download trac-63568-edge-case-fonts.zip. It holds one font for each case (case-NN-….woff2) and a control font (case-00-control.woff2, name Edge Control Sans). Each font is a copy of DM Sans Regular (SIL OFL 1.1) with a different family name in its name table. All the fonts have the same glyphs. Case 84 has no font, because a font name table cannot hold invalid UTF-8.

Steps for one case.

  1. Activate Twenty Twenty-Five.
  2. Delete all custom fonts in Appearance > Fonts. Many cases share a slug, for example O'Reilly Sans and O"Reilly Sans.
  3. Upload the font of the case in Appearance > Fonts > Upload. The upload activates the font.
  4. Open the home page.
  5. Run document.fonts.load('16px "<name>"') in the console, with the name as a CSS string. The result must hold one loaded face.
  6. Compare a text in the font with the same text in the control font. The widths must be the same.
  7. Check the stored fontFamily in GET /wp/v2/font-families. It must decode to one name with the exact text of the case, so that the preset uses the font.

Expected results.

  • Works: the upload succeeds, the face loads with the exact name, and the preset names the font.
  • Works (trimmed name): the same, but core and both upload clients remove a space at the start or the end of the name. The display name, the preset, and the face all use the trimmed name, so the preset selects the face. A PHPUnit test checks this.
  • Rejected: the REST API returns 400, or the editor sends no request.
  • Limit (…): the result is wrong with the upload client in core, for a reason that the server cannot fix:
    • comma, comment, escape, keyword, spaces: CSS reads the raw name in a different way. An upload client that sends a quoted CSS string, such as Gutenberg PR #76782, fixes these cases.
    • Cases 54 and 55 in browsers: Chromium 149, Chrome 153, and Firefox 151 do not use emoji and fangsong as generic keywords yet. So the text renders in the uploaded font, but the stored value is still a generic keyword, and the case fails step 7. A browser that uses these keywords shows a fallback font.
    • slug: the editor makes the slug with kebabCase(), which removes all non-Latin letters. The empty slug gives a 400 error. This needs a client fix.

Earlier browser results. I ran each case with Playwright in Chromium, through the Fonts page, as in the steps above. The core upload client is the Gutenberg build that trunk pins (5715a61), built locally. The last column used the Gutenberg plugin from PR #76782 (86cf7bad0d) with this PR at 4d54df7d10, which is before the commit that accepts raw names and the name 0. I ran all the cases again with this PR at 88508e2613 and the same Gutenberg plugin. Each case passed or failed as the last column shows. Case 44 (0) works at that revision because of that commit.

The table records results from earlier revisions. I did not repeat the complete upload test at 1a7bae7460. A comparison of all 85 raw-name cases found no change in sanitizer output between be5d8ec126 and 1a7bae7460. The selected PHPUnit groups also pass at 1a7bae7460. These checks support the documented PHP behavior; they do not verify the complete upload flow at the current revision.

Case Name Group Expected trunk This PR This PR + #76782
1 O'Reilly Sans quotes Works ✘ no face ✔ ✔
2 O"Reilly Sans quotes Works ✘ no face ✔ ✔
3 O'Reilly "Sans" quotes Works ✘ no face ✔ ✔
4 Suisse BP Int'l quotes Works ✘ no face ✔ ✔
5 ‘Curly’ “Quotes” quotes Works ✔ ✔ ✔
6 'Leading apostrophe quotes Works ✘ face Leading apostrop… ✔ ✔
7 Trailing quote" quotes Works ✘ face Trailing quote ✔ ✔
8 ACME, Sans punctuation Limit (comma) ✘ face ACME ✘ face ACME ✔
9 A;B punctuation Works ✘ face A ✔ ✔
10 A{B} punctuation Works ✘ no face ✔ ✔
11 A=B punctuation Works ✘ no face ✔ ✔
12 What? punctuation Works ✘ no face ✔ ✔
13 A:B punctuation Works ✘ no face ✔ ✔
14 Font (Display) punctuation Works ✔ ✔ ✔
15 Font [Beta] punctuation Works ✔ ✔ ✔
16 Font !important punctuation Works ✔ ✔ ✔
17 Dr. Font punctuation Works ✔ ✔ ✔
18 Font #1 punctuation Works ✔ ✔ ✔
19 Font @Home punctuation Works ✔ ✔ ✔
20 Font/Slash punctuation Works ✘ no face ✔ ✔
21 A/*c*/B punctuation Limit (comment) ✘ face A B ✘ face A B ✔
22 Bodoni* punctuation Works ✘ no face ✔ ✔
23 Jost* punctuation Works ✘ no face ✔ ✔
24 Rounded M+ 1c punctuation Works ✔ ✔ ✔
25 C++ Mono punctuation Works ✔ ✔ ✔
26 50% Gray punctuation Works ✔ ✔ ✔
27 Font 50%AB punctuation Works ✘ face Font 50 ✔ ✔
28 Font%2c Sans punctuation Works ✘ face Font Sans ✔ ✔
29 Font, Sans punctuation Limit (comma) ✘ face Font ✘ face Font ✔
30 A\B punctuation Limit (escape) ✘ face A[U+000B] ✘ face A[U+000B] ✔
31 Trailing\ punctuation Works ✘ no face ✔ ✔
32 \0030 punctuation Limit (escape) ✘ face 0 ✘ face 0 ✔
33 Tom & Jerry html Works ✔ ✔ ✔
34 Tom &amp; Jerry html Works ✔ ✔ ✔
35 A<B> html Works ✘ face A ✔ ✔
36 Test </style> Sans html Works ✘ face Test Sans ✔ ✔
37 </style><script>alert(1)</script> html Works ✘ no face ✔ ✔
38 <!-- x --> html Works ✘ no face ✔ ✔
39 url(javascript:alert(1)) html Works ✘ no face ✔ ✔
40 expression(alert(1)) html Works ✘ no face ✔ ✔
41 A"; color: red; x:" html Works ✘ no face ✔ ✔
42 A} body { color: red html Works ✔ ✔ ✔
43 12345 numbers Works ✘ no face ✔ ✔
44 0 numbers Works ✘ 400 (empty name) ✔ ✔
45 -1 Font numbers Works ✔ ✔ ✔
46 1942 report numbers Works ✔ ✔ ✔
47 Press Start 2P numbers Works ✔ ✔ ✔
48 --custom numbers Works ✔ ✔ ✔
49 -apple-system numbers Works ✔ ✔ ✔
50 serif keywords Limit (keyword) ✘ preset is generic serif ✘ preset is generic serif ✔
51 Serif keywords Limit (keyword) ✘ preset is generic serif ✘ preset is generic serif ✔
52 sans-serif keywords Limit (keyword) ✘ preset is generic sans-serif ✘ preset is generic sans-serif ✔
53 system-ui keywords Limit (keyword) ✘ preset is generic system-ui ✘ preset is generic system-ui ✔
54 emoji keywords Limit (keyword) ✘ preset is generic emoji ✘ preset is generic emoji ✔
55 fangsong keywords Limit (keyword) ✘ preset is generic fangsong ✘ preset is generic fangsong ✔
56 inherit keywords Limit (keyword) ✘ preset is keyword inherit ✘ preset is keyword inherit ✔
57 INHERIT keywords Limit (keyword) ✘ preset is keyword inherit ✘ preset is keyword inherit ✔
58 initial keywords Limit (keyword) ✘ preset is keyword initial ✘ preset is keyword initial ✔
59 unset keywords Limit (keyword) ✘ preset is keyword unset ✘ preset is keyword unset ✔
60 revert-layer keywords Limit (keyword) ✘ preset is keyword revert-layer ✘ preset is keyword revert-layer ✔
61 default keywords Limit (keyword) ✘ preset is keyword default ✘ preset is keyword default ✔
62 generic(kai) keywords Limit (keyword) ✘ preset is generic generic(kai) ✘ preset is generic generic(kai) ✔
63 A B whitespace Limit (spaces) ✘ face A B ✘ face A B ✔
64 Leading space whitespace Works (trimmed name) ✔ ¹ ✔ ¹ ✔ ¹
65 Trailing space whitespace Works (trimmed name) ✔ ¹ ✔ ¹ ✔ ¹
66 A[U+0009]B whitespace Limit (spaces) ✘ face A B ✘ face A B ✔
67 A[U+000A]B whitespace Limit (spaces) ✘ face A B ✘ face A B ✔
68 A[U+00A0]B whitespace Works ✔ ✔ ✔
69 A[U+3000]B whitespace Works ✔ ✔ ✔
70 A[U+200B]B whitespace Works ✔ ✔ ✔
71 日本語 😀 unicode Limit (slug) ✘ 400 (empty slug) ✘ 400 (empty slug) ✘ 400 (empty slug)
72 微软雅黑 unicode Limit (slug) ✘ 400 (empty slug) ✘ 400 (empty slug) ✘ 400 (empty slug)
73 MS ゴシック unicode Limit (slug) ✘ 400 (empty slug) ✘ 400 (empty slug) ✘ 400 (empty slug)
74 Ñandú unicode Works ✔ ✔ ✔
75 Café unicode Works ✔ ✔ ✔
76 Café unicode Works ✔ ✔ ✔
77 وزیرمتن unicode Limit (slug) ✘ 400 (empty slug) ✘ 400 (empty slug) ✘ 400 (empty slug)
78 A[U+202E]B unicode Works ✔ ✔ ✔
79 Dev 👩[U+200D]💻 unicode Works ✔ ✔ ✔
80 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA… boundary Works ✔ ✔ ✔
81 A\A\A\A\A\A\A\A\A\A\A\A\A\A\A\A\A\… boundary Works ✘ no face ✔ ✔
82 A[U+0000]B boundary Works ✘ face AB ✔ ✔
83 A[U+0001]B boundary Rejected (400) ✘ no face ✘ 400 (invalid value) ✔
84 A\xFFB boundary Rejected: no font file can hold invalid UTF-8. — — —
85 A[U+D800]B boundary Rejected (400) ✘ 400 (invalid JSON) ✘ 400 (invalid JSON) ✘ 400 (invalid JSON)
86 (empty) boundary Rejected (no name) ✘ 400 (empty name) ✘ 400 (empty name) ✘ not sent
87 boundary Rejected (no name) ✘ 400 (empty slug) ✘ 400 (empty slug) ✘ not sent

Control and invisible characters appear as [U+XXXX]. "No face" means that the browser has no face with the name, because the @font-face rule is invalid or missing. In the earlier browser tests, all 57 cases that must work passed with this PR. The 4 cases that must be rejected returned 400. The tested trunk revision passed 29 of the 57.

¹ The face loaded with the trimmed name, and the preset used the same name. The test measured the text width only with the untrimmed name, so these two cases have no width result. In the last column, case 83 works because that client escapes the control character.

CSS input values (53 cases)

These values come from a theme, a collection, or a REST client that sends CSS. Strict is the strict mode of the private parser, which KSES uses through is_valid_css_font_family(). Input is the plain name mode, which the REST API, theme input, and font faces use. Each entry shows its type and its decoded value.

Group CSS Strict Input
matrix Inter name Inter name Inter
matrix Open Sans name Open Sans name Open Sans
matrix "O'Reilly Sans" name O'Reilly Sans name O'Reilly Sans
matrix O'Reilly Sans Rejected name O'Reilly Sans
matrix 'O"Reilly Sans' name O"Reilly Sans name O"Reilly Sans
matrix "O'Reilly \"Sans\"" name O'Reilly "Sans" name O'Reilly "Sans"
matrix "ACME, Sans", sans-serif name ACME, Sans; generic sans-serif name ACME, Sans; generic sans-serif
matrix ACME\,Sans, serif name ACME,Sans; generic serif name ACME,Sans; generic serif
matrix "Tom & Jerry" name Tom & Jerry name Tom & Jerry
matrix "Tom \26 Jerry" name Tom & Jerry name Tom & Jerry
matrix "Tom \000026 Jerry" name Tom &Jerry name Tom &Jerry
matrix "Tom \000026 Jerry" name Tom & Jerry name Tom & Jerry
matrix "Font 50%AB" name Font 50%AB name Font 50%AB
matrix "A B" name A B name A B
matrix A B name A B name A B
matrix "12345" name 12345 name 12345
matrix "-1 Font" name -1 Font name -1 Font
matrix "What?" name What? name What?
matrix "A;B" name A;B name A;B
matrix "A{B}" name A{B} name A{B}
matrix "A=B" name A=B name A=B
matrix "A\\B" name A\B name A\B
matrix "O\22 Reilly Sans" name O"Reilly Sans name O"Reilly Sans
matrix "serif", serif name serif; generic serif name serif; generic serif
matrix "inherit", sans-serif name inherit; generic sans-serif name inherit; generic sans-serif
matrix Inter, generic(kai) name Inter; generic generic(kai) name Inter; generic generic(kai)
matrix "日本語 😀" name 日本語 😀 name 日本語 😀
matrix "A<B>" name A<B> name A<B>
matrix Inter/* comment */, serif name Inter; generic serif name Inter; generic serif
boundary "Inter" name Inter name Inter
boundary "" name name
boundary "0" name 0 name 0
boundary "A\[U+000A]B" name AB name AB
boundary "\41 B" name AB name AB
boundary "\41B" name Л name Л
boundary "\0" name [U+FFFD] name [U+FFFD]
boundary "\110000" name [U+FFFD] name [U+FFFD]
boundary "\D800" name [U+FFFD] name [U+FFFD]
boundary "A\" Rejected name "A\"
boundary Inter, Rejected name Inter
boundary -apple-system, BlinkMacSystemFont, sans-serif name -apple-system; name BlinkMacSystemFont; generic sans-serif name -apple-system; name BlinkMacSystemFont; generic sans-serif
negative "Inter Rejected name "Inter
negative Inter /* open Rejected name Inter /* open
negative "Inter" Bold Rejected name "Inter" Bold
negative "A"; color:red Rejected name "A"; color:red
negative "A"} body{color:red Rejected name "A"} body{color:red
negative url(javascript:alert(1)) Rejected name url(javascript:alert(1))
negative expression(alert(1)) Rejected name expression(alert(1))
negative generic(\6b ai\29 ;color:red) Rejected name generic(\6b ai\29 ;color:red)
negative generic(foo) Rejected name generic(foo)
negative inherit, serif Rejected name inherit; generic serif
negative Inter, , serif Rejected name Inter; generic serif
negative "</style><script>alert(1)</script>" name </style><script>alert(1)</script> name </style><script>alert(1)</script>

Current automated results. These local results cover commit 1a7bae7460 on PHP 8.3.31. The PHPUnit groups use a separate table prefix in the test database. A temporary test container maps example.com to a public address because the local DNS resolver did not resolve it. This permits the URL checks in tests that mock HTTP requests.

Command or check Result
phpunit --group fonts,kses,restapi-global-styles Passed: 1,333 tests, 4,285 assertions.
phpunit -c tests/phpunit/multisite.xml --group fonts,kses,restapi-global-styles Passed: 1,334 tests, 4,288 assertions.
PHPCS on the 5 files in commit 1a7bae7460 Passed: no errors or warnings.
PHP compatibility checks (PHPCompatibilityWP, PHP 7.4 and later) on the 2 source files in that commit Passed.
Chromium test with a custom face named -webkit-body Passed: the quoted preset loads the custom face. The unquoted preset loads no custom face.
Comparison of the 85 raw-name cases No change in sanitizer output between be5d8ec126 and 1a7bae7460.

The regression tests cover old face records, equivalent CSS escapes, duplicates across families, and records after the first batch of 100. They also permit different weights and distinct names whose old and new titles collide. The quote tests preserve named browser keywords and keep unquoted browser keywords.

GitHub checks at 1a7bae7460. Code style, PHP compatibility, and PHP static analysis passed. The PHPUnit workflow reports failures in 18 PHP 8.5 jobs. The PHP 8.5 / MySQL 8.4 job reports 231 errors from deprecated ReflectionMethod::setAccessible() calls in the font tests. I did not inspect the other failure logs. Thus the local results do not establish a pass for the complete GitHub matrix.

Earlier automated results. The following results cover be5d8ec126, except composer phpstan, which covers 526821468d. These are historical results. I did not rerun the full PHPUnit suites locally at 1a7bae7460.

Command or check Result at the earlier revision
composer phpstan 16 errors, none in a file that this PR changes.
PHPCS on the 19 PHP files that this PR changes Passed: no errors or warnings.
PHP compatibility checks (phpcompat.xml.dist) on the changed source files Passed.
phpunit (full suite) Passed: 26,182 tests, 4,562,389 assertions; no failures or errors; 86 PHPUnit deprecation warnings and 44 skipped tests.
phpunit -c tests/phpunit/multisite.xml (full suite) Passed: 27,026 tests, 4,564,560 assertions; no failures or errors; 86 PHPUnit deprecation warnings and 46 skipped tests.

Browser evidence and limits. The guide above records earlier browser results for 86 font files through the upload flow of the Fonts page. Those results do not cover 1a7bae7460. I ran the earlier complete test in Chromium 149, Chrome 153, and Firefox 151. In all three browsers, each case gave the result in the table. In Firefox, case 67 (A[U+000A]B) gives no REST request with the upload client in core, and the editor shows no notice. Chromium and Chrome send the request, and the font gets a wrong name. Both results fail, as the table shows. I did not test WebKit, including the -apple-system behavior in Safari. The test checks that the stored value decodes to the exact name, that a face loads, and that its metrics match the control font. It does not compare the rendered glyphs in a screenshot.

A comment on this PR has videos of the complete test in the three browsers. An earlier comment has videos of the upload flow at 6913a32d02.

Use of AI Tools

AI assistance: Yes

Tool(s): Claude Code and Codex

Model(s): Claude Opus 5, Claude Opus 5.5, and Claude Fable 5.1; GPT-6.

Used for: The implementation, code review, code reduction, regression tests, test execution, and PR description.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

Core applied text operations to CSS `font-family` values. Those operations
changed a font name or made invalid CSS. A name with an apostrophe produced
the invalid declaration `font-family:O'Reilly Sans;`, so the browser did not
use the font. A name with a comma became two families. `sanitize_text_field()`
also removed percent sequences, collapsed spaces, and stripped markup.

Add `WP_CSS_Font_Family`. The class reads the CSS `font-family` grammar and
returns the decoded name of each family, with its type. The serializer writes
a decoded name back as a quoted CSS string. It escapes the quote character,
the backslash, the control characters, and `<`, `>`, and `&`, so that a name
survives HTML output and the KSES post filters.

Use the class in these places:

- `WP_Font_Utils::sanitize_font_family()` replaces `sanitize_text_field()`,
  `explode( ',' )`, and quote trimming.
- `WP_Font_Utils::get_font_face_slug()` compares decoded names, so that
  equivalent CSS escapes produce one slug.
- `WP_Font_Face_Resolver` selects the first family of a list from the parsed
  entries.
- `WP_Font_Face` writes the `@font-face` descriptor as a quoted CSS string.
- Both font REST controllers reject a `fontFamily` value that is not valid
  CSS and not a plain font name.
- `WP_Font_Collection` sanitizes the nested `fontFace.fontFamily` value.
- `safecss_filter_attr()` splits declarations with quote and escape
  awareness, and validates `font-family` with the font family grammar.

A named family is now always quoted, and a generic family stays a keyword.
For compatibility, a plain font name such as `O'Reilly Sans` still works at
the font input boundaries. Core does not require a client-side escape scheme.

Props matiasbenedetto.
See #63568.
matiasbenedetto and others added 9 commits September 18, 2026 14:46
…ter.

Use `mb_chr()` to decode a hexadecimal escape, copy the continuation bytes of
an escaped character in place, and remove three private helpers. Simplify the
identifier loop, and remove guards that cannot fail.

In KSES, check a `font-family` value inside the allowed-property branch and
clear the test string when the grammar accepts it. Remove the parenthesis
depth tracking from the splitter, because a font name with a semicolon is
always a quoted string.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Use bounded regular expressions and native string operations to remove duplicate parser and serializer code. Copy complete CSS declarations in the KSES splitter.

Reject undefined generic arguments before CSS serialization. Add parser and REST regression tests for escaped declaration delimiters.
# Conflicts:
#	src/wp-includes/kses.php
#	tests/phpunit/tests/kses.php
Use one regex to reject CSS syntax characters and control characters in a
plain font name. Remove the PLAIN_NAME_REJECTED_CHARACTERS constant.

Move the escape check into consume_identifier() and remove
is_valid_escape(), which had one caller.

Use a regex to copy the escaped UTF-8 character in consume_escape(). The
input is valid UTF-8, because parse_list() rejects invalid UTF-8.

The behavior does not change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Remove the WP_CSS_Font_Family class. WP_Font_Utils now holds the parser
and the serializer as these methods:

- parse_font_family_list()
- parse_font_family_list_with_plain_names()
- parse_font_family_descriptor_name()
- serialize_font_family_name()
- serialize_font_family_list()

The private helper methods and the constants get names that include
"css" or "font family", because WP_Font_Utils also holds other methods.

Move the parser tests to tests/fonts/font-library/wpFontUtils/.

The behavior does not change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n name lists.

Split style declarations at each semicolon again in safecss_filter_attr(),
and remove _wp_kses_split_css_declarations(). The quote-aware splitter
read a quote inside an unquoted url() as the start of a string. A
declaration after it, such as `behavior: url(x.htc)`, then got past the
allowlist. serialize_font_family_name() now writes a semicolon in a name
as a CSS escape, so a split at each semicolon keeps the name.

Keep a font name that is one identifier of letters and hyphens unquoted,
as WordPress 6.5 did. Safari reads `-apple-system` as a system font only
without quotes.

Remove the quotes from a font name in get_font_face_slug(), and apply
sanitize_text_field() to the other parts of the slug and to a value that
the parser rejects, as WordPress 6.5 did. A font face that an earlier
version saved keeps its slug, so the duplicate check finds it.

Parse each entry of a font family list on its own. An entry that is not
valid CSS is a plain name up to the next comma. A comma inside a quoted
name no longer splits the name, and an empty entry is ignored.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`WP_Font_Utils::serialize_font_family_name()` now writes a comma as a
CSS escape. Some clients split a font family list at each comma and do
not read quoted strings. The Gutenberg Font Library preview function
`formatFontFamily()` is one example: it read `"ACME, Sans"` as the two
names `ACME` and `Sans`, so the preview used a fallback font.

The decoded name does not change. The Gutenberg client already escapes
the comma in the same way in `createCssString()`.

See #63568.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion.

Keep the original private method name
`WP_Font_Face_Resolver::maybe_parse_name_from_comma_separated_list()`
and change only its body. In `WP_Font_Utils::get_font_face_slug()`, call
`get_font_family_comparison_key()` inside the existing lines, so that the
assignment alignment of `$defaults` and `$settings` does not change.

The behavior does not change.

See #63568.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
matiasbenedetto and others added 3 commits October 5, 2026 14:16
The upload client in core sends the name from the font file as it is,
for example `Bodoni*` or `Font (Display)`. The plain name path rejected
the CSS syntax characters, so the REST API returned a 400 error for
these names. trunk accepts them.

Now a raw entry that is not valid CSS becomes one font name. Only a
value with control characters is an error. The serializer escapes every
character that CSS or HTML reads, so the name stays inert.

Accept the font name "0" in both font REST controllers. The check for
an empty required setting read "0" as a false value.

See #63568.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A font file can name its family with a space at the start or the end.
Both upload clients trim that name, and core trims a raw name too. Test
that the display name, the preset, and the face descriptor all use the
trimmed name, so that the preset selects the face.

See #63568.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@matiasbenedetto

matiasbenedetto commented Oct 6, 2026 •

Copy link
Copy Markdown
Author

Testing evidence: 86 edge-case fonts in Chromium, Chrome, and Firefox

These videos test all the fonts from trac-63568-edge-case-fonts.zip with the steps of the font name test guide in the description. For each case, the video does these steps:

  1. A script deletes all fonts.
  2. The video uploads the case font in Appearance → Fonts → Upload.
  3. A script sets the font as the body text font in Global Styles.
  4. The video opens the home page and runs four checks:
    • The stored fontFamily decodes to one font name with the exact text of the case (step 7).
    • document.fonts.load() finds a loaded face with that name (step 5).
    • Text in that name has the same width as text in the control font (step 6).
    • The body text on the page has the same width as the control font.

Case 1 plays at normal speed. Cases 2–87 play at 5× speed, with a scoreboard. Each video ends with a summary table that compares each case with the guide.

Setup: wordpress-develop Docker environment, Twenty Twenty-Five, Playwright 1.61.1. The core upload client is the Gutenberg build that trunk pins (5715a61).

Setup Chromium 149 Chrome 153 Firefox 151
trunk at 57c4558589 29 of 57 29 of 57 29 of 57
This PR at 88508e2613 57 of 57 57 of 57 57 of 57
This PR + #76782 at 86cf7bad0dc 57 of 57 57 of 57 57 of 57

The table shows how many of the 57 cases that must work pass. In all nine runs, each of the 86 cases gives the result that the guide shows.

trunk at 57c4558589

Chromium 149

edge-1-trunk-chromium.mp4
Chrome 153 and Firefox 151

Chrome 153

edge-1-trunk-chrome.mp4

Firefox 151

edge-1-trunk-firefox.mp4

This PR at 88508e2613

Chromium 149

edge-2-core-pr-chromium.mp4
Chrome 153 and Firefox 151

Chrome 153

edge-2-core-pr-chrome.mp4

Firefox 151

edge-2-core-pr-firefox.mp4

This PR + Gutenberg PR #76782 at 86cf7bad0dc

Chromium 149

edge-3-core-and-gutenberg-prs-chromium.mp4
Chrome 153 and Firefox 151

Chrome 153

edge-3-core-and-gutenberg-prs-chrome.mp4

Firefox 151

edge-3-core-and-gutenberg-prs-firefox.mp4

Notes

  • Case 67 (A[U+000A]B) in Firefox: with the core upload client, the editor sends no REST request and shows no notice. Chromium and Chrome upload the font, but with a wrong name. Both results fail, as the guide shows. With #76782, the case works in all three browsers.
  • Cases 54 and 55: all three browsers render the text in the uploaded font, because they do not use emoji and fangsong as generic keywords yet. With the core upload client, these cases fail step 7.
  • The script writes the body font directly to the global styles post, as an admin save does. It does not use the Styles UI.
  • I did not test WebKit or Safari.

Add these tests for Trac #63568:

- A data provider with the cases of the font name test guide in the PR
  description. Each raw name goes through the REST API, and the test
  checks the documented result: the exact name, the trimmed name, the
  known CSS reading, or a 400 error. The cases include non-Latin names
  and invisible characters.
- Slug pairs that must stay different: "Font%2c Sans" and "Font, Sans",
  and the same letters in Unicode NFC and NFD.
- HTML-like names for a user without `unfiltered_html`. KSES filters the
  post content for that user, so the test checks that the stored name
  does not change. The test fails if the serializer does not escape "&",
  "<", and ">".
- A literal entity in the shared data set of the data path tests.

See #63568.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
matiasbenedetto and others added 2 commits October 6, 2026 11:24
Replace the five new public methods of WP_Font_Utils with two methods
that return plain values:

- `is_valid_css_font_family()` returns true for a valid CSS value. KSES
  uses it.
- `get_font_face_family()` returns the quoted `@font-face` name, or an
  empty string. WP_Font_Face, the resolver, and the font faces
  controller use it.

The parser, the serializers, and the keyword constants are now private,
so the array of parsed entries is not a public contract. The font
families controller uses `sanitize_font_family()` to check the value.

The font faces controller now rejects the empty quoted name `""`.
Earlier, WP_Font_Face rejected that face at output time.

The tests call the private parser and serializer through reflection.

See #63568.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant