Skip to content

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

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

matiasbenedetto wants to merge 3 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, percent sequences, CSS escapes, and punctuation. It keeps a named family such as "serif" distinct from the generic keyword serif.

Implementation. The new internal class WP_CSS_Font_Family parses CSS values and serializes decoded names.

  • parse_list() returns typed entries for named families, generic families, and reserved keywords. It consumes the complete value.
  • parse_list_with_plain_names() adds compatibility for plain names that earlier WordPress versions accepted, including O'Reilly Sans and O"Reilly Sans.
  • parse_descriptor_name() selects the first family for a face, including the supported legacy list input.
  • serialize_name() emits a quoted CSS string with escapes for quotes, backslashes, control characters, and HTML-sensitive characters.
  • serialize_list() preserves family order and the distinction between names and generic keywords.

The parser uses anchored regular expressions for identifier bytes and strspn() for hexadecimal digits. It uses no recursion. The serializer replaces only characters that require an escape. The KSES splitter copies complete declaration substrings and respects quotes and escapes.

The generic() parser accepts only kai, fangsong, khmer-mul, and nastaliq. It validates the decoded argument before it emits CSS. This prevents an escaped argument from introducing a second declaration. Tests cover valid escaped arguments and rejection through both the parser and REST.

The font utilities, resolver, face output, collection schema, and both REST controllers use the shared parser. Invalid REST values return a 400 error. Invalid face values produce a diagnostic and no face output. Core loads the parser before KSES.

safecss_filter_attr() uses the font grammar to accept punctuation inside valid names. Other values still use the current safety checks. The safecss_filter_attr_allow_css filter remains in the path.

Compatibility. Named families now use quotes in canonical CSS. Generic families retain their keyword form. The preset can contain "ACME, Sans", sans-serif, while its face descriptor contains only "ACME, Sans".

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.

Font face comparison keys use decoded names so equivalent CSS escapes identify the same face. Percent sequences protect punctuation that can otherwise change key boundaries or post titles. Ordinary names such as Open Sans retain their previous key format. This PR performs no bulk data migration.

A value with additional CSS syntax, such as "A"; color:red, fails REST validation. In a style attribute, font-family:"A;B";color:red remains two permitted declarations. The semicolon inside the quoted name stays part of that name.

Client dependency and limits. Core accepts valid CSS without the special escape scheme from Gutenberg PR #76782. Upload clients must serialize metadata names with commas, parentheses, or angle brackets to preserve each name as one CSS string.

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.

Current automated results. These checks cover commit 7c82a49985.

Command or check Result
npm run test:php -- --group fonts,kses,restapi-global-styles Passed: 1,169 tests, 3,698 assertions.
npm run test:php -- -c tests/phpunit/multisite.xml --group fonts,kses,restapi-global-styles Passed: 1,170 tests, 3,701 assertions.
npm run typecheck:php -- --no-progress Passed: no errors.
PHPCS on the five files in the latest commit Passed: no errors or warnings.
PHP compatibility checks on those files, with testVersion 7.4- Passed.
npm run test:php 26,611 tests, 4,561,027 assertions; one failure, 86 PHPUnit deprecation warnings, and 44 skipped tests.

The full-suite failure is Tests_Script_Modules_WpScriptModules::test_default_script_module_files_exist. The local environment lacks src/wp-includes/js/dist/script-modules/dashboard-init/index.js.

The same test fails identically with the source code from PR base 74bb9334f5129ad64bab5b630157067bf5f24931. The control run restored all eight modified pre-existing source files through temporary Docker mounts. It retained the same local build assets. The test and script-module registration code do not differ from the base. The failure therefore does not originate in this PR.

A comparison across 60,516 inputs checked the three parser entry points, name serialization, and the KSES splitter against the previous PR revision. The only differences were the intended rejections of invalid generic() arguments. The corpus included random input, every byte value, and long names, lists, comments, and escape sequences.

The regression suites cover REST storage and responses, repeated saves, duplicate detection, legacy records, generated presets, face output, and injection attempts. The new invalid-argument tests failed before the validation fix.

Browser evidence and limits. The earlier implementation report records Chrome 143 checks at revision a5795c3, with Twenty Twenty-Five and a local DM Sans fixture. A REST-installed family named O'Reilly, Sans loaded after a public page reload. Its canvas metrics matched a control face from the same file and differed from fallback fonts. A name with HTML-like text remained inert. Those checks did not repeat for the latest commit.

The Site Editor upload workflow, a new Playwright spec, Firefox, WebKit, and the two Trac font attachments remain unverified through the interface. The current automated results above do not claim those checks. CI results for earlier revisions do not establish the result for this commit.

Use of AI Tools

AI assistance: Yes

Tool(s): Claude Code and Codex

Model(s): Claude Opus 5 and Claude Fable 5.1, as recorded by the implementation author; GPT-6 for this review.

Used for: The implementation, code review, code reduction, regression tests, test execution, and PR description. The latest review reproduced the generic-argument defect and compared the build-file failure with the PR base.


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.

@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 2 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant