The 7.0 font subsystem: remove the core installer, add language packs and the new Font Manager - #1722
jakejackson1 wants to merge 99 commits into
Conversation
|
Coverage report for commit: d66c4d3 Summary - Lines: 92.34% | Methods: 86.94% | Branches: 80.61%
🤖 Jest coverage report |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Coverage report for commit: d66c4d3 Summary - Lines: 85.28% | Methods: 93.32%
🤖 PHPUnit coverage report |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
7d5350b to
d6324b6
Compare
| /** @var \GFPDF\Model\Model_Actions $model */ | ||
| $model = \GPDFAPI::get_mvc_class( 'Model_Actions' ); | ||
|
|
||
| $model->undismiss_notices( [ 'install_core_fonts' ] ); |
There was a problem hiding this comment.
Don't think it could be dismissed.
44c9b59 to
d66c4d3
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub. |
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
Rebases mpdf/mpdf#2161 onto upstream development and extends it per §9.18: line-break dictionaries as font-package data, a FontRegistry that no longer fatals without composer.lock, a container-resolvable fontFileFinder, and the default font sorted first in fontdata. The work sits in a scratch clone and is not pushed yet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7.0 hands mPDF its fonts through the FontRegistry from mpdf/mpdf#2161 instead of relying on the ttfonts/ directory php-scoper never copied. The fork branch is upstream development @ 389e19e plus that PR rebased and extended: line-break dictionaries as font-package data, a FontRegistry that no longer fatals when composer.lock is absent, and a container-resolvable fontFileFinder. vendor/mpdf/mpdf drops from ~110MB to 4.9MB — the font packages are export-ignored on the fork and Composer does not read a dependency's own path repositories, so nothing pulls them in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every install currently has to click "Download Core Fonts" and stream ~110MB before a PDF renders at all. These five faces ship with the plugin instead, so an English-language site generates a correct PDF the moment it is activated, offline, with true bold and italic. Arimo covers Latin, IPA, diacritics, Greek, Cyrillic, Vietnamese and Hebrew in all four faces and matches Arial's metrics, so existing templates keep their layout. The DejaVu subset is substitution-only and exists mainly for the tick and cross the Consent and Terms of Service fields emit, which no comparable sans-serif carries — without it mPDF falls back to unembedded ZapfDingbats and PDF/A renders a notdef box. The Arimo faces are dehinted: outlines, glyph order and cmap are identical to upstream, a third smaller, and every generated PDF shrinks too because mPDF embeds subsets with hint instructions no viewer runs. fonts/ adds 856KB compressed, against the 900KB the plan budgets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`get_translations()` is the one place a pack id is frozen plugin-side, and it still held the twenty-pack list the 2026-09-10 coverage audit replaced. `serif-mono`, `dejavu`, `cjk-ext-b`, `hebrew-syriac`, `ancient-scripts` and `barcode` no longer exist; `west-asian`, `insular-southeast-asian` and `central-asian` were missing. A label the plugin has not shipped degrades to the index's own English string, so this breaks nothing — but the ids have to reach a release before the matching packs publish, or the first three packs a site sees are the three whose names cannot be translated. The ids, labels and order are the pipeline's own pack table rather than a transcription of the plan's, so the two halves of the cross-repo interface come from one source. A test pins the seventeen in order: a stale id is dead weight nobody notices. The re-cut seed does not ride with this, though the plan said it would. `Catalog_Sync::seed()` reads a published source index, not the pack table, so it still waits on the first published tree along with its conformance test. The ids wait on nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 5 adds a second font source. Everything under it has keyed on `source` / `entry` / `coverage` / `meta` since Phase 3 so that a new one costs no code — but nothing tested the claim, and a claim like that is worth less than the test of it. `Test_Rest_Font_Source_Extensibility` registers `acme` through `gfpdf_font_sources`, on its own origin, signed with its own key, and drives the whole lifecycle — sync, list, search, install with chosen variants, update, remove — through the routes every other source uses. Trust is per record, so the second half of the claim is that registering a source is not a way to borrow ours: a root signed with the wrong key installs nothing, and every request to a third party's origin carries no query string at all. It doubles as the rehearsal for `google`, which is the same shape: a slim index whose entries are fetched one file at a time, display families rather than coverage packs, styles chosen per install. The `google` record itself is not registered here — it is the one line the plan promises, and adding it before the pipeline publishes an index would put a permanent empty "Browse Google Fonts" entry on every site. Writing it found one defect, fixed here. `Catalog_Sync::request()` — the Refresh button — returned on the first root it could not reach. With one origin that is invisible, which is why Phase 3 never saw it; with two it means an outage at ours hides an add-on's updates and, because the return came before the pass could see a change, skips scheduling the sync that would have taken them. It now asks every root and reports a failure only when nothing was scheduled, leaving the per-record `last_error` to the sources listing and the health check. `Font_Sources::flush()` is the counterpart every other font class had: the records are built once a request, and a source registered on a later hook was invisible for the rest of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second built-in, and the one line the plan promised it would be: a `Font_Source` beside `packs`, on the same root so the two cost one root request between them, with empty `request_args` and its own description. Nothing downstream branches on which source a row came from — the entry's own `coverage` flag decides whether it is a language pack or a display family, one row at a time — so registering it is the whole of the plugin's half. Until the pipeline publishes a `google` index the source lists with `total: 0` and `synced: null`: `sync_root()` logs and skips a registered source the root does not list, so the state is "never checked" rather than an error. Four tests had quietly assumed one built-in and now say what they meant. `Catalog_Sync_Check` names every stale source in its single issue, and a new case pins that one source syncing does not answer for another; `is_due()` stays true while any source is still owed a sync; and the sources listing is two records, the second empty rather than hidden. The mock REST layer already served both sources for the Font Manager to browse. Its records now carry the same descriptions the plugin ships and report `coverage` as a count rather than a boolean, which is the shape `GET /fonts/sources` actually returns — a fixture layer is only worth having while it answers in the routes' shapes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4 built the Font Manager against a mocked `/fonts/` that returned the §4.7 shape, but the route itself still
returned 6.x's flat custom-font list. This closes that half, which is what the UI needs before it can be pointed at
the live routes.
`Registry::get_grouped_fonts()` now returns `{ bundled, groups, custom }` — the bundled faces, one group per
installed coverage entry in the catalogue's `position` order, and everything else, which is where a catalogue
display family belongs because an admin thinks of a font they chose and named the same way whether it came from a
CDN or their desktop. `get_installed_fonts()` flattens the same structure into the settings dropdown rather than
building its own, so a pack's label and the order the packs appear in have exactly one source. That also settles a
disagreement waiting to happen: `selectBox.js` rebuilt the bundled optgroup as "Bundled" where the server had
rendered "Bundled Fonts", which nothing has noticed only because the rebuild is still gated off behind the mock.
Every face carries the `url` a preview needs. `Font_Repository::get_font_dir_url()` resolves it once per request
and answers `null` when `gfpdf_font_location` has put the directory outside the web root — the same `null` a face
flagged `missing` gets, so a preview asks for a URL and is told there isn't one rather than being handed a 404.
`sha256` is deliberately left behind: it is how the installer decides whether to re-fetch a file.
Two fields belong to the request rather than to the model and so stay on the route. `missing` lists the `always`
entries with nothing installed, for the Bundled panel's offer. `can_delete_files` asks §4.11's question
all-or-nothing — of a row this site does not own — because the panel has to decide whether to draw a button before
it knows which row the admin will press it on; on multisite a tenant administrator gets `false`, which is the same
answer `DELETE` would give them.
`get_all_items()` keeps returning the 6.x custom-font list, because `GPDFAPI` and `get_custom_fonts()` still read
it; the route now calls `get_font_list()` instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Going to point the Font Manager at the live routes turned up more of §4.7 unbuilt than the settings route I knew
about: `POST /fonts/`, `POST /fonts/{id}` and `DELETE /fonts/{id}` were still the 6.x custom-font routes
underneath a UI written against the mock. This closes all of that except `variants`.
Both writes now answer with the row rather than the 6.x absolute-path array. That is not cosmetic: the store
merges a write straight into the list it already holds, and a response shaped unlike the list forces a second
round trip to make sense of. `Registry::get_font()` is the single-row form of the builder `GET /fonts/` already
uses, so the two cannot drift.
`POST /fonts/{id}` takes `{ enabled }` on multisite, which is the per-site visibility toggle and the one font
write multisite treats differently — installation is network-wide, being able to choose the font is not. On single
site there is nowhere to hide a font from, so the key is refused rather than quietly ignored.
Both it and `DELETE /fonts/{id}` answer `400 font_owned_by_entry` for a row a language pack installed, naming the
pack, instead of the generic `invalid_font_id` the id guard was giving them. The pack decides which keys exist:
removing one would leave an entry the catalogue still calls installed rendering with a face that is gone, and the
entry route takes the whole pack, which is the only unit an admin can act on.
Separately, `/fonts/sources` and `/fonts/sources/{source}` emit `synced` and `last_attempt` as ISO-8601 instead of
the integers the option record stores. The record counts seconds because comparing two syncs is what it is for,
but the header's sync line hands both straight to `Date.parse()`, which answers NaN for a unix stamp — and every
other date on these routes was already a string.
Two tests asserted the 6.x upload/edit response; they now assert the same facts through the row shape, with
`useOTL` and `useKashida` read off the repository, which is where they live.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`POST /fonts/{id} { variants }` is the last row of §4.7's table, and the last thing the Font Manager needed before
it can be pointed at the live routes.
Choosing styles for one row is the same upsert the entry route already runs for all of them, so the deciding half
moves out of `Rest_Font_Installs` into a `Fonts\Install_Requests` service: which files an install needs, what an
existing row reads back as, and whether a style the body names is one the entry publishes. None of that was ever a
REST concern, and two controllers each holding an installer and a queue would have been a second update path,
which §4.3 says there must not be. The controller keeps what is genuinely about the request — the install cap, the
lost-claim 409, the status map it answers with.
The installer matches an install on its `label`, which is what makes aiming this at a single row work: handing it
this row's own label finds this row and no other, so a sibling install of the same family keeps the styles it was
installed with without the route having to say so. A row that came from an upload has no entry to take styles
from, and asking for them is `400 font_has_no_styles` rather than a silent no-op.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every `/fonts` route the Font Manager calls now exists except `/fonts/settings`, which is Phase 6, so the app registers the fixtures for that one path and lets WordPress answer the rest. `mockOnly()` is the same dispatch table the mock already had, scoped to a list of paths; the unscoped `mockMiddleware` stays because the Jest suite runs against it, and a component test that had to stand up REST fixtures would be testing WordPress rather than the component. Two things Phase 4 left switched off come back with it. The settings dropdown's install sentinel and `watchFontList()` were gated behind `isMocked()`, because writing a fixture key into the dropdown would have let a PDF be saved against a font the site does not have. Both are live now — which is also what made the optgroup label disagreement fixed a couple of commits ago a real bug rather than a latent one. Checked against a running site rather than only in the suite: `GET /fonts/` answers the three groups with plugin-directory URLs for the bundled faces, `/fonts/sources` lists both records as never-synced, `/fonts/status` is an empty object on a fresh install, and `/fonts/settings` is the 404 that keeps it on fixtures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Written while the fork work was in flight and left uncommitted since, because this branch's commits were the font installer. It covers fork PR #38 and its two follow-ups (#57 → PR #60, #61 → PR #63): how the keep-together measuring pass is rolled back through the whole-object snapshot, which of the nine clustered issues turned out to have their own causes, and the snapshot-comparison and rendering tooling that works without Imagick. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`syncSelect()` removes every optgroup carrying `data-gfpdf-font-group` and writes the list again, so that an add-on's injected group survives the rebuild. Nothing on the PHP side ever wrote the attribute, so the rebuild had nothing to replace: with the Font Manager now reading the site rather than fixtures, the settings page drew its own "Bundled Fonts" group and the rebuilt one beside it. `build_options_for_select()` takes the `label => id` map beside the options, `select_callback()` passes `$args['optgroup_ids']`, and the two font fields supply it from `get_installed_font_groups()` — which projects the same list `get_installed_fonts()` flattens, so a group's label and its id cannot come from two places. `register_settings()` had to learn the key as well: `add_settings_field()` copies a fixed set of args, and one missing from that list reaches no screen. That is the half a unit test reading `get_registered_fields()` would have missed, so the case here renders through the registered settings instead. `font-manager.spec.ts` moves with it. The fixtures its assertions named — "Brand Sans", a Google catalogue with four monospace families — do not exist on a real install, so it asserts what does: one bundled family, no Custom or Language packs heading, and a never-synced catalogue offering its Refresh button. The lifecycle and install runs wait for the HTTP stub, because seeding a font row would put it in the Font dropdown of every snapshot the `core` project takes beside this spec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A cleanup pass over the span of this branch that had not had one. Nothing here changes behaviour.
`Install_Requests` was extracted so that the two routes which install a catalogue entry share one update path,
but half the decision stayed at the controller: `Rest_Custom_Fonts` held a `Catalog_Repository` purely so
`install_variants()` could resolve the entry and validate the styles itself. That work is a catalogue question,
so it moves behind `queue_variants()`, and the repository leaves the REST layer — which drops the controller's
constructor from nine arguments to eight at all four construction sites.
The same extraction had left `entry_id()` and the coverage test living in two classes at once. Both now have one
home on `Install_Requests`, which is where `on_record()` needs them; `Rest_Font_Installs` calls across. `for_row()`
was public with no caller outside its own class.
`Registry::paths_of()` was a second spelling of `Font_Repository::paths_for_rows()`, which this class already
calls a hundred lines further down — and the loop around it re-split a `{source}/{entry}` key it had just
composed, when every font in hand carries both halves verbatim. `set_visibility()` took a whole `WP_REST_Request`
to read one parameter and answered `void|WP_Error`, where the caller wants to know refused-or-not.
`font_groups()` no longer builds the choice lists, because `get_registered_fields()` asks for the group ids as
often as it asks for the fonts and only one of those two wants them flattened.
On the test side, `install_real_font()` had re-typed the trait's default row to change one field; `install_font_row()`
now takes the fixture instead, so the row shape stays in one place. `queued_installs()` moves to
`QueuesFontInstalls` beside `queued()`, replacing the nested `array_column` the sibling suite was spelling inline.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fonts store was going to carry an indefinite object lock on `v1/files/`. That was withdrawn on 2026-09-14 and `prune` against retained roots replaces it, so bytes a site's catalogue still names really can be deleted. Nothing in the plugin read a 404 as anything in particular. `Font_Downloader` turned every non-200 into `font_http_error`, the entry went `failed`, and it retried the same dead URL at a 6-hour backoff while nothing re-read the index — so a site recovered only at the next 30-day sync, or when an admin pressed Refresh. It was silent, too: a render falls back to the bundled faces, so the symptom is a PDF in the wrong font rather than an error, which is quieter and worse for being quiet. 404 and 410 now come back as `Font_Downloader::GONE`, from one `http_error()` all three request paths share, and `Catalog_Sync::resync_stale()` queues a sync out of turn behind an hour-long `Font_Lock` debounce. The lock is taken and never released: `acquire()` hands the next caller a takeover once the TTL is past, which is the sliding window wanted, and without it a withdrawn pack is a thundering herd on the root across the install base. `resync_stale()` deliberately does not `spawn_cron()`, though the plan specified it. Core hooks `_wp_cron()` on `shutdown` and reads the due list there, so an event queued at `time()` is spawned at that request's shutdown anyway, off the critical path. Spawning by hand blocks up to a second — `wp_remote_post()`'s 0.01s timeout is `ceil()`ed to 1 by the curl transport — and under `ALTERNATE_WP_CRON` answers a GET by redirecting it. Trigger 3 reaches this from inside `begin_pdf()` and a PDF download is a GET, so on those hosts the specified behaviour would redirect the render. Refresh keeps its spawn; an admin is waiting on that one. `fail()` is not the only exit that matters. `plan_for()` and `files_for_installs()` hand a resolve error straight back, and `Install_Queue::files_to_queue()` turns it into an empty plan — no rows, no status, nothing logged. A resolve fetches the entry file for any source that does not inline it, which is how `google` is built, so that is where a pruned entry is most completely silent. All three route through one `checked()`. 7.0.0 rather than a follow-up: 7.0.0 is the first version with a catalogue at all, so shipping the branch in it means there is never an install base without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds `POST /fonts/import`, the offline half of the installer: a site that
cannot reach fonts.gravitypdf.com carries a published package in by hand and
gets the same install every other trigger performs. §4.6 has named this since
the first draft and §4.7 never registered a route for it, which is the
contradiction that kept the Add menu item unbuilt.
The archive is never believed about itself. One string is read out of it,
`package.path`, and only as a key to find a catalogue row with; every byte is
then checked by size and sha256 against that row. A crafted zip claiming to be
a pack this site has synced installs exactly nothing.
Four things worth knowing about the build, all recorded in the plan:
The archive cannot name `{source}/{entry}` — the pipeline embeds the entry
object, and the id and version live on the row around it, which is not in the
zip. So identity comes from columns: `entry_for_package()` matches the
published `{entry}-{version}.zip` name. That answers for a display entry too,
which matching on the embedded field could not.
Not `Catalog_Font_Adopter`, against what the route table says. It is
coverage-only, skips a font key already taken (so an update writes nothing),
and bypasses `set_status()`, `warm()` and `FlushCache`. The importer places
the files and lets the ordinary queued install run instead — `already_installed()`
makes each download a no-op, and updates and the polled status map come free.
A pointer entry is read out of the archive, never fetched. `resolve()` fetches
the entry file for any row that does not inline one, which is how `google` is
built: 20s of blocking HTTPS and then a failure, on exactly the host this
feature exists for. `Font_Installer::offer_entry()` seeds `fetch_entry()`'s
memo, which is keyed by content hash precisely so a document cannot be the
wrong one.
Members are checked both ways before a byte is written, so a thirty-file pack
missing its last file is refused whole rather than after twenty-nine renames.
That check is also the whole of the traversal rule: the entry's filenames are
bare validated segments, so `../evil.ttf` is simply not one of them.
Four spellings that were about to be duplicated moved to one owner:
`Font_Sources::entry_lock()` (the lock name), `Font_Lock::acquire_entry()`
(the protocol and its refusal), `Font_Repository::move_into_place()` (the
atomic rename both writers need) and `Font_Downloader::verify_file()`, now
public because "is this what the entry lists" has no version that should be
true of a download and false of an extraction.
The UI half is not built: the picker, its three surfaces and the host-cap
guidance a 413 carries. `AddFontMenu.js` records what is left.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An offline import identifies its archive by name, and the name was being rebuilt in SQL as CONCAT(entry, '-', version, '.zip'). That put the pipeline's naming grammar in two repositories with nothing gating them equal, and P5b has to change it: a display family's archive needs a source segment to stay unique in the flat `files/` prefix, which a rebuilt name would turn into a coordinated release across both. The index row now carries the archive's object name in a `package` column (schema 7.0.2, added by dbDelta, no migration entry). `Catalog_Sync::to_row()` stores it verbatim with no fallback to the copy inlined in `entry`: the two are gated equal upstream, and reading the inlined one here would be the second answer that lets them drift. `entry_for_package()` is now `WHERE package = %s`, unindexed on purpose — one read per hand-driven upload, against a table a sync rewrites whole. The column is nullable in name only: `upsert()` binds every column as %s, so a row that published no archive holds '' rather than NULL. Hence the empty-name guard, which the test now actually exercises by seeding a row with '' instead of relying on `insert_catalog_row()`'s omission. Store-side changes are in gravitypdf-update-server: `entries.mjs` emits `package` and derives `entry.package.path` from it, `schema.mjs` states the grammar once on the row and gates the two carriers equal or both absent, and `packages.mjs` writes the archive under the row's name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
POST /fonts/import landed without the UI that reaches it, and AddFontMenu carried a comment saying so.
ImportDialog is that UI: one modal, reached from all three surfaces the spec names — the Add menu, the
packs browser (gated on a source that publishes packs, since nothing else has an archive to import) and
a coverage entry's footer. FontManager owns the open flag and passes one onImport down, so the three are
entry points rather than three implementations.
The dialog takes no source or entry, deliberately. The route takes a file and nothing else: the archive
names the entry and the server matches it against the `package` column. So opening it from the Japanese
pack and choosing the Korean archive installs Korean, and the copy says so — a per-entry variant would
have had to lie about what the route accepts.
Two things worth knowing about the request. It sends a FormData *body* rather than apiFetch's `data`
shorthand, which JSON-encodes what it is handed; the archive has to arrive as an ordinary $_FILES entry,
which is also what a host's upload cap applies to. And past the 202 an import is an ordinary queued
install, so the dialog seeds the status map, navigates to the entry's page and lets the poller carry it.
The store's error map now keeps the WP_Error code beside the message. The import has to tell "pick a
different file" from "nothing will ever upload here" — the host's cap, or no ZipArchive — and that is a
reading of the code, which the map dropped. So SET_ERROR stores { message, code }, getError() is
unchanged for its three existing readers, getErrorCode() is new, and run() passes the code through:
importPackage stays an ordinary write rather than the documented exception it started as.
The guidance names FTP and the docs page, not WP-CLI: `wp gpdf fonts install --from=` does not exist
yet, so naming it would be a lie.
The mocked route is the only place in the front end that knows the {entry}-{version}.zip grammar, stated
once and commented as the store's rule — the plugin matches the column and must never relearn to rebuild
a filename.
Testing Library's global asyncUtilTimeout goes to 2.5s. The fixtures answer on a 220ms timer, so a pane
reading three routes in sequence was already two-thirds of the 1s default before four Jest workers
shared a core, and seven new tests tipped it into flaking. Held under Jest's own 5s per-test limit so a
query that never resolves still fails as "unable to find an element", with the DOM, rather than as a
bare test timeout.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Font Manager's custom-font upload has never worked against the live route. Four faults, each of
which alone was enough:
1. CustomFontDetail kept `file.name` at the drop and threw the File away, so the bytes never left the
browser.
2. uploadFont()/editFont() posted through apiFetch's `data` shorthand, which JSON-encodes what it is
handed — a File encodes to {} — while POST /fonts/ reads $_FILES.
3. The request keys were the role ids. Every read returns R/I/B/BI, but the custom-font write still
speaks the 6.x regular/italics/bold/bolditalics (Rest_Custom_Fonts::$font_keys, mirrored by
Font_Repository::LEGACY_FACE_ROLES, which outlives the migration because the public API and every
add-on use it). So even multipart under role ids would have been refused.
4. The re-seed effect depended on the `row` object, and GET /fonts/ answers with a fresh document — so
every background refresh, and the poller runs one whenever any unrelated install settles, reset the
form and took the chosen files with it. Those are not retypeable; they come off disk.
`files` now holds a filename for a face already on the row and the File itself for one chosen since,
which is the whole of the distinction facesToWrite() needs: a face is replaced exactly when it holds a
File and cleared exactly when the row had one and it is gone. That also fixes a missing confirm —
re-picking a face under the name it already had skipped the destructive-change dialog while still
shipping new bytes. writeFont() sends multipart when there are faces and keeps the JSON path for
`variants` and `enabled`, where an object and a boolean survive as themselves. The role/legacy
translation is a `field` on each ROLES entry, so both vocabularies sit on one row. The effect keys on
row?.id, and commit() reads the saved row back — better anyway, since move_fonts_to_font_dir() saves
under a suffixed name when the fonts dir already holds one.
Why nothing caught it: the mock read `data.files` and invented a row from the filename, so every
component test passed against a request the real route would have refused. Worse, respond() handed back
the mock's own objects, so the store's identity checks passed for a reason production does not have —
with that in place fault 4 is invisible even to a test written for it. respond() now serialises every
response as a real one is, the mock enforces add_item()'s one required part (a `regular` File), and the
panel tests assert against the mocked server's rows rather than the panel's local state, which had been
showing the admin a face that was never written.
No PHP change: Test_Rest_Custom_Fonts already posts `regular` as a file param, so the server contract
was pinned all along and only the client was wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ager The Font Manager had a Playwright spec but no visual baseline, and the offline import dialog added in af68fc5 had no browser coverage at all. One new test drives the whole offline path: Add menu → dialog → a real multipart upload to POST /fonts/import → the 404 the route answers with, rendered back into the dialog. It asserts only what needs a browser, a built bundle and a real request to fail — the dialog's own branches are the Jest suite's, and the route is Test_Rest_Font_Installs'. That division is the point: the bug the upload fix repaired was invisible to both halves separately. Three Chromatic snapshots, on the bundled detail, the never-synced browser, and the dialog over the manager. Each isolates the modal and its ancestors, so the surrounding admin chrome is not baselined. The manager is targeted by class in the dialog snapshot because it leaves the accessibility tree while a second modal sits on top of it. The fixture declares a package name no published catalogue can carry. Naming it after a real pack — emoji-fonts-v1.0.0.zip is what the store publishes for packs/emoji — would have made "the catalogue does not list this package" true only because the table is empty, so a site that had synced would not fail the test, it would quietly exercise a different refusal. It carries the manifest and nothing else, because the lookup that refuses it runs before a font file is read. It also needed a .gitignore negation: a personal global `*.zip` rule hides it from `git add`, and a fixture missing from a commit only shows up as a red CI run. Two things recorded in the spec header while here. The whole file's "no packs, neither catalogue downloaded" premise rests on WP_HTTP_BLOCK_EXTERNAL in tools/wp-env/e2e.json, since a blocked sync records last_attempt and never synced — lift that flag and these assertions go red for a reason nothing in the file points at. And the two lifecycles this spec cannot cover, a custom font's add/edit/delete and a catalogue entry's install run, are not untested: Test_Rest_Custom_Fonts and Test_Rest_Font_Installs drive both routes for real, uploads included. What is missing is the browser half alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `core` project matches `(core|permalinks)/…` and `core-with-permalinks` matches `permalinks/…`, so anything under `permalinks/` runs twice. Neither manager depends on permalink structure — they are admin-screen React modals reached from a PDF settings page, and nothing in either spec touches a shortcode, a download URL or a post link — so the second pass re-proved the first at full cost. The placement dates from the TestCafe migration (28bce35) rather than from a decision. Moving the directory under `core/` is the whole fix; the projects match on path, so no config changes. 17 tests stop running a second time and the suite goes from 96 test runs to 79. Both specs import through the `@self:playwright` alias rather than relative paths, so the depth change reaches nothing, and no CI config or documentation referenced the old path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ourth D4 — `Font_Sources::validate_entry()` validated a file's name and `remote_path` and stopped, so the two fields the whole install-time security model rests on went unchecked. Not exploitable: the runtime fails closed anyway, since `hash_equals( '', … )` is false and a zero size mismatches. What it cost was *where* a build mistake surfaced — `font_size_mismatch` mid-install on a customer's site instead of a refused source index at sync. `sha256` must now be 64 lowercase hex and `size` a real int above zero. An int, not merely numeric, because the published artefact emits one and accepting "10" is accepting a build that has started stringifying its numbers. `Install_Queue`'s `size <= 0` arm went with it: it is unreachable through a validated entry, and it set `over_cap`, so had it ever fired it would have told the admin a face was too large when it was in fact unsized. D3 — `Registry::installed_package()` passed `[]` as `Package`'s aliases, so `font-family: eeyekunicode` in a 6.x template resolved to nothing after the 7.0 key renames: no error, no fallback notice, the PDF simply rendering in the document font. The same silent shape as GF 3.0 dropping `RGForms`. An entry now publishes `aliases` as `alias => font key`, the same map `language_to_font` already is, checked by the same table plus two rules of its own — an alias is charset-checked like the key it stands in for, and may not equal a key the entry registers. `installed_package()` keeps its `[]`, which is the fix rather than a shortcut. mPDF merges every package's `getFontAliases()` into one `fonttrans` at construction, so only one list needs building and it has to be the one that can see every row: that is what enforces `array_diff_key( $aliases, $rows )`, an alias deferring to a real font of the same name. `bundled_aliases()` is renamed `font_aliases()` for no longer belonging to a layer. The inversion sits beside `coverage_meta()` rather than inside it, because that builds mPDF's fallback arrays, which only a coverage pack has, while a rename is something any source can do — Google has renamed a published family. Gating aliases on coverage would have let a display family's rename pass validation and be dropped at row-write time, which is this defect one layer down. D8 — `sync_root()` stamped `last_attempt` and moved on when the content had not changed; `synced` was bumped only inside `replace_source()`. So `synced` tracked the store's last *publish* rather than this site's last successful *verification*, and `is_stale()` reads `synced`. Thirty days of a quiet catalogue put the whole fleet on `RETRY_BACKOFF` and raised `Catalog_Sync_Check` against a store answering perfectly — training every admin to ignore the one signal that would show a freeze attack. A verified root that still names the index this site holds now counts as a sync, and clears `last_error`, so the Font Manager's "last refresh failed" stops outliving the failure. The stamp sits below `source_changed()`, not above: the changed path reaches it through `replace_source()`, and stamping first would mark a source fresh whose index then failed to arrive or parse — the same defect pointed the other way. `verified()` is `fail()`'s counterpart and the only writer of `synced`. D2 — withdrawn, having been written and taken back out. The specified plugin half was to refuse an entry whose `remote_path` basenames collide, and it fires on exactly the wrong entries. `remote_path` has one consumer, `Catalog_Repository::url_for()`, which concatenates it into a download URL; the local name is the `files` map key, which PHP already guarantees unique. So two families' licences at `a/LICENSE.txt` and `b/LICENSE.txt` under keys `a-LICENSE.txt` and `b-LICENSE.txt` are lossless — two files fetched, two written, nothing shadowed — and the rule refused them, while the entry the defect is actually about still validated, because the four files the builder dropped left no trace to compare against. One bad entry refuses a whole source index, so that is a third-party catalogue lost for a well-formed index. The invariant is only checkable where both sides of the collision still exist, which is the builder: D2 is the pipeline's P3 and nothing else. Verified against the live catalogue, not just fixtures: all 17 published packs pass the tightened validation unchanged, and `indic`'s 14 licence files are already named per family. Every new gate was mutation-tested — the first pass found two that did not bite, one because a `files` override renamed the file and the entry was already being refused as "role names an unlisted file", and one because both syncs in a test landed in the same second. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cked
`GET`/`POST /fonts/settings` is the final entry in the client's `PENDING_ROUTES`, so the Language tab stops
reading fixtures and the shim that fed it — `mockOnly()`, `enablePendingRoutes()`, the array itself — goes with
it. `api/mock/` is now the Jest seam and nothing else.
`Registry::language_map()` is the settings screen's view of the two maps §4.4 already computed: one row per code
in `$default ∪ $effective`, grouped the way `get_grouped_fonts()` groups the sidebar so a label and an order can
only be wrong in one place. It files a row under its *default* font rather than its current one, so a code moved
elsewhere stays with the pack that claimed it and an admin can see what they changed it from. "Changed" stays
derived — `font !== default_font` — rather than becoming a second fact that can disagree with the first.
The sentinel is `*` on the wire in both directions, not the `''` §4.4 specified. The panel's font select carries
a `*` option and no empty one, so `''` would have drawn as "no selection" and saved back as whatever option came
first. `Registry::LANGUAGE_NONE` is now the one spelling, shared with `effective_language_map()` and with the
client through `constants.js`.
Two defects fell out of pinning the §4.4 behaviour table, both older than this change:
`get_document_script()` looked up `Ucdn::LATIN`, a constant mPDF does not define — the select has always offered
the bare script name — so every value of that setting was silently ignored and the script fell back to the one
the document language implies. `Registry::script_constant()` now owns the vocabulary: it folds mPDF's `SCRIPT_`
prefix either way, and the route refuses a name no constant answers to rather than storing a `BANANA` that looks
like a setting and does nothing. `Registry::scripts()` ships the offerable list so the client's third copy could
be deleted.
`Model_Form_Settings::settings_sanitize()` wrote `[]` for any unanswered single-value select, which is a
`TypeError` in `strtoupper( $settings['pdf_size'] )` under PHP 8 and a `font-family: Array` in the stylesheet.
It writes `''` now; `multicheck` and a `multiple` select still get an array. `Registry::setting()` stays anyway,
because sites carry years of `[]` already on disk and the reader is what makes those readable — it replaces the
one-off `is_string()` guard `Coverage_Resolver` had grown for the same reason.
`prune_language_overrides()` lives on `Registry` beside `effective_language_map()`, its read-side twin, rather
than in the controller that posts to it: the upgrade routine, a repair pass and WP-CLI all have to be able to
write a valid override map without restating the rule. `default_language_map()` is memoised on the repository
stamp, since three callers of one request were each re-sorting every installed row to build it.
The four keys are deliberately *not* registered as settings fields. §4.8 asked for that so they would be
"sanitised/exported with the rest", and they would be neither — `settings_sanitize()` is tab-scoped and there is
no settings export tool — while a section the sanitiser *can* reach has its unanswered fields blanked, which for
`font_language_overrides` means an unrelated save wipes the map. Per-PDF `pdf_language` *is* registered, in
`form_settings_appearance`, where the framework genuinely carries it: rendering, sanitising and a REST enum.
`Language_To_Font::labels()` is a table of ~190 language codes and ~59 `und-<script>` ones — the union of every
code a published pack claims, every code the frozen 6.x map carries, and the Latin-script languages the default
select needs but no pack has to. It is memoised per locale, because one of its callers is
`get_registered_fields()` and that runs on every REST request the site serves, `GET /download/{entry}/{pdf}`
included. An unnamed code degrades to the raw code; a test locks the one code set that is knowable in advance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0d68f57 to
63362ca
Compare
… floors The floors were owed from Phase 1 (§9.6) and never landed: `required_wp_version` still said 5.3 and `required_gf_version` still said 2.5, while Phase 3 shipped `Helper_Abstract_Queue::dispatch()` calling `dispatch_on_shutdown()` — a Gravity Forms 2.9.25 API. A site on GF 2.5 would have activated cleanly and fatalled the first time a font install queued. The version bump is what makes the 7.0 upgrade routine reachable at all. `PDF_EXTENDED_VERSION` was still 6.16.0, so `maybe_run_upgrade()`'s `7.0.0` gate — `ensure_ready()` plus the inline catalogue fill — had never once run outside the test suite. Bumping alone would not have fixed it. PHP sorts a pre-release below its own final version, so `7.0.0-alpha1 < 7.0.0` and a gate naming the final release goes quietly dead for the whole alpha window. The gate now names the alpha, which is the shape the 6.0 gate three branches above already uses. A site that has run it stays shut for alpha2, the betas, the RCs and 7.0.0 final, because each of those fails the `old_version < 7.0.0-alpha1` half. `test_7_0_0_fills_the_font_catalog_inline()` now drives the gate with `PDF_EXTENDED_VERSION` rather than a literal, which turns it into the drift guard as well without a second fixture; `Test_Pre_Checks` gains a test pinning the three floors against the plugin header, the only other place two of them are declared. Both were mutation-checked. Known and accepted for the alpha window: a template declaring `Required PDF Version: 7.0.0` is now judged incompatible by `Helper_Templates::is_template_compatible()` and hidden from the selector, since `7.0.0 <= 7.0.0-alpha1` is false. All four bundled templates declare `4.0-alpha`, so this reaches only add-on or customer templates written against 7.0, and it resolves itself at 7.0.0 final. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ass found A `/simplify` sweep over the branch as one diff, which is the first time the seven phases have been read side by side. Most of what it found was cross-phase: mechanisms that were sound when their own phase landed and only read as duplication once the phase after them existed. Three were defects, recorded as D12-D15 in §11 along with the two that are left open. **The install poller stopped dead on the first quiet tick** (D12). It armed a one-shot `setTimeout` and relied on its own effect re-running to arm the next, but `RECEIVE_STATUSES` deliberately returns the same state object when the map has not changed — so the first read that found nothing moving left `getStatuses()` identical, `useSelect` did not re-render, the effect did not re-run, and no next timer was ever set. One file slower than the 2 s first interval froze every progress reading in the modal for the rest of the session while the install carried on underneath. The back-off table was unreachable with it: the second and third intervals needed a quiet tick that could not arrive, so §4.6's "backs off while an install runs, settles into a heartbeat" described behaviour the code could not produce. The chain re-arms itself from inside `read()` now and the effect depends on `running` alone, which gives the reducer's dedupe back the job it was written for. The suite tested the wrong half — `interval()` and `moved()` are pure and were both correct. **"Install new fonts…" threw instead of opening the manager** (D13). `addInstallSentinel` took a third `previous` argument and the one caller passed two, so choosing the sentinel threw inside the listener: the select stayed pinned to it and the modal never opened. That is the discoverability path §4.6 puts on the dropdown for every language pack. The fix deletes the parameter rather than passing it — the util owns the `change` listener and so sees every value the select passes through, which makes "the last non-sentinel value" its own state and not something a caller can forget. The test supplied a third argument the product did not, so the suite was green over a dead wire. **An entry's install form reset under the admin mid-edit** (D14). `EntryDetail` keyed its prefill effect on the `row` object, which `getRow()` rebuilds whenever the fonts slice is replaced, so every background refresh — the poller runs one whenever any unrelated install settles — discarded the name typed and the styles chosen. `CustomFontDetail` carries a comment describing this exact hazard and keys on `row?.id`; this one had not been given the same treatment. `Model_Custom_Fonts::update_font()` discarded `insert_file()`'s return exactly as `write_rows()` had before D1, so a face that failed to record left the font row alive with no file row while the route answered 200 from the in-memory array. Noted on D1 rather than numbered: the deeper fix is a contract that cannot be ignored, which is D15's second half. **The Jest fixtures were shipping to every admin.** `api/index.js` statically imported `./mock`, so 1,716 lines of pack catalogue — including a hand-maintained clone of `Registry::status_object()` — landed in the production bundle, which nothing could tree-shake while `enableMockApi()` referenced them. Moved under `tests/js-unit/`, where `renderWithStore()` registers the middleware itself; `api/index.js` no longer knows the mock exists. 75,498 to 68,602 bytes minified. **`filterLabel()`'s own comment said "the routes will translate, until they do".** They do: `Rest_Font_Sources` ships a translated label on every filter object and `SourceBrowser` was discarding it in favour of a second table that had already drifted — `Sans Serif` against the route's `Sans-serif` — and was missing eleven ids the route knows. The table is gone and the mock publishes labels the way the route does, so the fixture states the contract instead of hiding it. **`Font_Migration` kept the shape `Font_Population_Pass` was created to abolish.** It was a constructor field plus a dedicated call above the loop, which left the bootstrap's `/* Order matters */` comment governing two of the three passes and the first one running outside it — the "field per collaborator" growth the interface replaced. It is a pass now, registered first, and the whole ordering lives in one comment where the order is chosen. `Catalog_Font_Adopter` re-implemented the size-then-hash check inside the security boundary; `Font_Downloader::verify_file()` is public precisely so that "is this byte-for-byte what the entry lists" has one implementation whatever put the bytes there, and the offline importer already deferred to it. `Registry::STUCK_AFTER` duplicated `Install_Queue::STALLED_AFTER` — same value, same meaning, two places to change. `Font_Sources::join()` retires seventeen inline `source . '/' . entry` concatenations whose inverse, `split()`, already lived there. And three `Rest_Custom_Fonts` routes still called the whole-directory `FlushCache::flush()` while each knowing the exact key that changed, despite `flush_font()` having been added in this branch because the blunt sweep forces every other font to re-parse — seconds, on a 17 MB CJK face. `invalidateSearches` looked dead and is not: `refreshSources()` never cleared the search cache, so after a sync the browser answered from pages built against the old index. Wired rather than deleted. Three memos and one reordering. `Coverage_Resolver::legacy_map()` and `Registry::registered_keys()` are the siblings `default_language_map()`'s memo was written for and did not get — four and six-to-eight rebuilds of the same walk per request. And `Install_Queue::enqueue()` built `claimed_filenames()` one line above the `is_claimable()` gate whose comment promises an in-flight install "costs one indexed row"; it did not. The closed modal no longer fetches. `getFonts()` and the poller both ran above `if (!route) return null`, so every PDF settings page load cost two REST round-trips for an admin who never opened the manager, and a background install polled a dialog that was not on screen. Splitting the route gate from the shell also retires the effect that reset `adding`/`importing`, which unmounting now does. Dead with the old manager: `Spinner.js`, `Alert.js` and their tests, two RTL media-query partials that were nothing but a deleted selector, and roughly 115 lines of orphaned rules across four more. Suites: PHPUnit 2,335 single-site and multisite, Jest 385 across 45 suites, Playwright core 67 of 68 — the remaining failure is the `setPreferences` timeout inside `@wordpress/e2e-test-utils-playwright` that fails identically with the branch stashed. phpcs, lint:js and lint:css clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`DOCS_OFFLINE_INSTALL` named `docs.gravitypdf.com/users/installing-fonts-offline/`, a page that has not been written and is no longer planned as part of this work. The constant feeds an `ExternalLink` an administrator clicks in the import dialog — the one path offered to someone whose host refuses the upload outright — so shipping it would hand exactly that person a 404 at the moment they have no other option. It now points at the documentation home, which at least lands them on something, with an `@todo` to repoint it once the page exists. Lowercase, because ESLint's `jsdoc/check-tag-names` rejects `@TODO` and the repo's two existing tags are lowercase too. `ImportDialog.test.js` asserts the href contains `docs.gravitypdf.com` rather than the full path, so it still covers the link without pinning a URL that was always going to move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Testing the language packs against the staging font store found 15 of 112 installed faces refusing to parse: 14 threw `GPOS Lookup Type 5, Format 3 not supported (ttfontsuni.php)` and `notosansjavanese-R` threw a `count()` type error. Five whole packs — arabic, indic, southeast-asian, insular-southeast-asian and central-asian — landed in `failed` as a result, which is every complex-script pack and so the entire reason language packs exist. The trigger is upstream of us: the store now publishes faces byte-identical to their sources apart from hinting, having dropped the GSUB rewrite mPDF no longer needs. That rewrite was evidently also flattening GPOS constructs this build could not read, so removing it exposed contextual positioning the parser had never implemented. `28aec23` carries PR #82, which implements the remaining contextual GSUB and GPOS lookups, so the throw is gone from `TTFontFile` entirely. It also guards `glyphToChar[$glyphID][0]`, which is worth as much again: unencoded glyphs — ligatures, marks — resolved to null there and then fell into `dechex(null)`, which is where ~1,379 warnings per full pack install were coming from. All 112 faces now parse, all 17 packs install clean, and a full install run emits no warnings under `E_ALL`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three surfaced while testing the language packs against the staging font store, and all three share a shape: something went wrong and nothing downstream could tell. `Catalog_Sync::run()` assumed its tables existed. `Font_Repository::ensure_ready()` builds them on the first query of a *request*, and a scheduled sync on a fresh install is a request that makes none — so cron, the one caller that can arrive before any page load has run, wrote every row of the pass against a table that was not there. It now ensures the schema first. Deliberately `Font_Schema::ensure()` and not `ensure_ready()`: the migration passes and the version stamp stay the repository's to own, and a sync has no business claiming either is done. `run()` then returned `true` over that pass regardless. `sync_source()` and `sync_root()` were `void`, so the booleans `replace_source()` and `fail()` were already producing died one frame below the only caller that could act on them: a site could record `last_error: "Table 'wp_gravitypdf_font_catalog' doesn't exist"`, sync nothing, and report success. Both now return, and `run()` ANDs them. Every group still runs whatever one of them does — an outage at one root must not hide another's updates — and which source failed stays in its `last_error`, because one boolean cannot carry S of them. `Catalog_Font_Adopter` verified size and sha256 and stopped there, making it the one path into the font tables that skipped the parse every install performs. The hash proves the bytes are the ones the source published; it cannot prove they are a font this build of mPDF reads, and byte-perfect faces that throw are exactly what the change above shipped. A pack that failed to install was therefore adopted clean the moment its files were on disk — observed directly, and how the parse failures briefly looked fixed when they were not. It now warms each key after inserting it, since the row is what makes the face findable at all, and takes back out any key mPDF refuses. Rows only: the files were on disk before the run and are not this class's to remove. Adoption's fixtures moved from marker strings to the bundled Arimo faces, because `.ttf` files holding `emoji-bytes` are now refused exactly as a corrupt download is; the two deliberate-mismatch cases keep theirs. Each new test was confirmed to fail with its own fix backed out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`338cb7c` is PR #117, which closes #90, #91, #92 and #112 along with the five follow-ups the first round left behind. It deduplicates the OpenType reading code `TTFontFile` and `OtlDump` held four copies of between them, separates the parser from the subsetter, cuts `Otl::applyOTL` from 1,070 lines to 80, and replaces the five `not YET TESTED` throws in the dump's GPOS handling with all six formats. Nothing here asked for it, which is the point of recording why it was taken anyway: it is a refactor of exactly the code that reads our font files and writes the subsets that go into a PDF, so the cost of finding out later that it moved something is higher than the cost of pulling it now, while the faces it affects are still what we were last looking at. Verified against a baseline captured on `28aec23` before the bump: all 112 installed faces still parse, and 14 rendered samples — Latin, Arabic on both faces, Devanagari, Bengali, Telugu, Myanmar, Javanese, Sinhala, Kannada and all three CJK — come out byte-identical, embedded font programs included, compared by sha256 over the PDF's streams rather than by page size. A clean install of all 17 packs still lands 66 fonts with nothing failed and no warnings under `E_ALL`. Those scripts are the ones that exercise the contextual lookups the refactor rewrites, so identical subsets across them is the assertion worth having. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three checks the upload path could not make before the mpdf fork split its font reading into `src/Fonts/`, and one it always could and did not. **Table checksums.** `TTFontFile::getMetrics()` takes a fourth argument that compares every table against the checksum the font's own directory states for it, and we were passing nothing. It matters more than it sounds: altering 64 bytes inside Arimo's `GSUB` throws at no stage, parses, registers, and renders a byte-identical PDF for any text that misses the damage — the wrong glyphs appear silently, later, in a document. The checksum is the only thing in the path that notices. Partial by mPDF's own rule, since `maxStrLenRead` skips tables of 200 KB or more, so this covers the character map, the layout tables, the names and the metrics and leaves the outlines unchecked. All 117 font files on hand pass it, bundled and installed alike, which is the answer to the real risk here — plenty of fonts in the wild ship checksums their editors never recomputed. **Faces that are not what their slot claims.** `FontFaceAnalysis` reads the style bits and family name out of each uploaded file through mPDF's `TTFontFileAnalysis`, which exists for exactly this and stops before the layout tables a render would need. Nothing today stops the bold file going into the italic slot, and nothing about the PDF says so afterwards: it renders upright where italics were asked for and the admin finds out in a document. Reported and not refused, because a face with its style bits unset renders exactly as well as one that sets them, and blocking an upload over a metadata bit would be worse than the mismatch. This repository's own REST fixtures turn out to be the case in point — a condensed sans in the italic slot, a serif in the bold-italic one — so the test asserts against those rather than something contrived. **Kashida.** Every OTL font was given `useKashida = 75`, which is Arabic justification and does nothing on a font with no RTL coverage. Now gated. The flag is coarser than it first appears: `extractCoreInfo` merges Hebrew and Arabic into one bit, so Arimo answers true off its Hebrew range. It errs in the safe direction — a font that needs Kashida never loses it — and across the 66 fonts installed here it removes the setting from 57 of them. `update_item` gates only the automatic value; a Kashida the request states is the admin's to state. Warnings ride back with the saved row and are held in the store under the id it saved as, not the one the form had, so they survive the move off "new" onto the row — which is the only place the admin is still looking. The JS mock gained an explicit `uploadWarnings` seam rather than pretending it can read a font. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment said the coarse RTL flag was a cost worth paying, which invites the next reader to go and pay less of it. It is not a cost. `Otl::shapeArabic()` is the only place kashida markers are written and `Mpdf::GetJspacing()` only inserts kashida where it finds one, so `useKashida` is inert by construction on anything that is not Arabic, Syriac, N'Ko or Mandaic — flipping it between 0 and 75 leaves a Latin page byte-identical on a Latin font and on an Arabic one alike, and moves a justified RTL Arabic page by 46 bytes. That makes both ways of being wrong harmless rather than merely cheap: a font covering Arabic gets Kashida and should, and one covering only Hebrew never fires it, Hebrew not being cursive-joining. Which is the argument against splitting Arabic out of the flag — it would mean re-reading the cmap in a class that exists to delegate that parsing, to change a stored integer and no rendered page. Noted alongside that `pregCURSchars` starts at Hebrew and runs through Arabic, so mPDF's own idea of a script needing cursive handling covers nearly the same range the flag does. No behaviour change; the test's version of the same claim is brought into line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ery request for it
`KEY_PATTERN` is a character class, so `sans` and `gfpdf-arimo` pass it as readily as `notosanssc` does. A
coverage pack claiming one reached `Font_Installer::upsert_font()`, which refused it — rightly, the pack's row
would shadow a font that ships with the plugin — by logging and returning a zero row id that `write_rows()
`continue`d over. The file was already on disk by then, with no row to claim it, so `install_complete()` could
never be satisfied: the entry sat at `installing` with no error and no `retry_after`, and the Font Manager's
Retry re-POSTed into the same dead end. D1's shape exactly, one layer up.
`Font_Repository::is_key_reserved()` is `public static` now, as `is_valid_role()` became for D1, and
`validate_entry()` reads it — so the entry is refused at sync, where a build mistake costs the source index
rather than every site that installs the pack. Coverage entries only: a display entry publishes a key it never
installs under, since `new_key()` chooses that and `unique_key()` already routes it off anything reserved, so
refusing one would reject an entry that installs fine.
The flag is a parameter and not a field lookup, which is the part worth stating. `coverage` lives on the index
row — `Catalog_Sync::to_row()` reads it off the entry *beside* the document while validating the document
*within* — so a gate derived from `$entry['coverage']` is false always and the check silently never fires. It
comes from the two callers that hold the authoritative flag instead, the same one `upsert_font()` reads. And
`upsert_font()` returns a `WP_Error`, which closes the other door the zero covered: `insert()` returns 0 on a
failed write too. `resolve()` validates every entry it installs, inlined or fetched, so that refusal should now
be unreachable — it is tested by subclassing past `resolve()`, the way D1's test subclasses past
`insert_file()`.
Separately, `Rest_Form_Settings` composed its full item schema on `rest_api_init`, which fires for every REST
request the site serves in any namespace. That built every Gravity PDF settings field on each one — the font
dropdown and the three queries behind `Registry::get_grouped_fonts()`, ~252 translated language names, the
template enum off disk — for `GET /download/{entry}/{pdf}` and for every REST request any other plugin on the
site makes. Measured at ~6.6 ms that none of them read, and a regression against 6.x, where the dropdown was a
literal array and one cached option.
Registration defers it, and `maybe_set_template_schema()` — already a `rest_pre_dispatch` handler that
re-registers, which is what makes this possible at all — moves to priority 5 and gains the two index routes that
publish our args. Nothing reads a route's args in between. The guard sits at `get_item_schema()` rather than at
each `args` entry because there are two doors into the settings fields: the obvious
`get_endpoint_args_for_item_schema()`, and core's `get_context_param()`, which composes the whole schema just to
derive the `context` enum. A foreign namespace and the download route now build the fields zero times and walk
the template directories zero times; our own routes build once, down from twice.
Caching the composed schema, which is what the plan called for, is not possible: it carries 14 closures in its
`arg_options` and `serialize()` throws on them. The eight `is_file()`/`filesize()` calls the plan also named cost
0.005 ms, cold stat cache and all, so the generated constant for them was not worth writing.
Each new test was confirmed to fail with its own fix backed out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…m went nowhere §11 D20. `Font_Sources::font_row()` had one human string to work with — the entry's own label — so it used that where an entry registered a single font and fell back to the mPDF key everywhere else. On a fully provisioned site that is 61 of 66 rows: the Default Font select and every select in the Font Manager offering `notosansbengali`, `daibannasil`, `jetbrains-mono`. Nobody chose the key as a fallback; it is what a schema with no per-font name leaves behind. The names are already in the bytes we ship. `FontFaceAnalysis` — the class the upload path uses to warn that a face is in the wrong slot — reads the `name` table, and over all 66 installed faces it answered correctly for every one, in 148.7 ms. So a row's label is now the first answer on a ladder: the name the entry publishes, the family the file reports, the entry's label where it registers exactly one font, the key. Rungs 1, 3 and 4 live in `font_row()`; rung 2 cannot, because `write_rows()` upserts the row as each file lands and rewrites `label` every time, so a name written before the last file arrives is overwritten by it. `Font_Namer` writes it where the install is finished, beside the `warm()` that already parses every face — while `Catalog_Font_Adopter`, which writes each row once and has the file in hand, names before its insert instead. Coverage rows only: `existing_install()` matches a display row on `(source, entry, label)`, so renaming one would make the next re-install miss the row it is updating and suffix a duplicate key. The same class backfills rows installed before any of this existed, as a population pass scoped to rows still labelled by their key — 61 renamed here in one batched write. That pass needs a `Font_Schema::VERSION` a site does not hold, and the population it is for is the site that installed packs on an earlier 7.0, so the constant moves to `7.0.3`: a fresh install and a 6.x upgrade both reach the pass loop with no coverage rows at all, those arriving later from a catalogue install that names its own. Pre-release that costs one dbDelta and three idempotent passes. The pipeline's half of rung 1 is not in this commit; when it lands, every entry version bumps and each pack rewrites its own labels on update. §11 D21. The Font Manager's default-language select showed `en-us (en-us)` where every other option shows a name, and the language table under it showed a bare code for every regional override. `labels()` names bare languages and scripts, plus the two Chinese regional codes a pack claims, and WordPress has no `en` locale at all — the Site Language option stores `''` for English (United States) and `get_locale()` turns that into `en_US`. So the derived default is `en-us`, on most sites in the world, and the route's fallback rendered it raw. `Language_To_Font::label_for()` walks the same `candidates()` ladder the render resolves through and takes the first rung the table names: `en-us` takes English's name, `pt-br` Portuguese's, and a code no rung names is still shown as itself. Both readers of that response use it — `Registry::language_map()` as well as the route — since the table prints the code beside the name regardless. The stored code does not move — narrowing that would change the tag mPDF is handed on every render and the tag `Coverage_Resolver` triggers installs from, both of which reach Traditional Chinese through exactly the rung D22 adds. §11 D22, the plugin half. A pack's catalogue row claims the languages it is installed *for* and its entry's `language_to_font` is what those claims resolve *through*; the two are published separately and nothing on a site compared them. `chinese-traditional` claimed `zh-hk,zh-tw` and routed nothing, so a Taiwanese site downloaded the right 7 MB pack and drew every document in the Simplified face — `zh-tw` widens to the `zh` Simplified holds. `Catalog_Sync` now says so at sync and the System Report lists it for what is installed, both through `Language_To_Font::unrouted_claims()`. Neither refuses the index, unlike D1 and D15: the pack installs and works when a template names its font, and the gate that would have caught it belongs in the build that publishes both halves. Run against this site's 17 packs it finds a second one — `central-asian` ships `notosansmongolian` and publishes `scripts: mn,und-Yiii` with routes for the Yi tags only. That one is deliberate, so a claim a pack declares it means not to route is subtracted rather than reported. `Catalog_Sync::nullable_string()` cuts by character rather than by byte, for the reason the new `Font_Sources::display_name()` does: `notes`, `preview` and `preview_text` are `varchar(255)` too, so a CJK string was being cut at ~85 characters and could hand `$wpdb` half a sequence. Separately, `Test_Render_Font_Trigger` asserted the inline fetch without pinning `REQUEST_TIME_FLOAT`, so `within_budget()` measured from the PHPUnit process rather than from the case. Every case there passed alone and in a fast group, and the first assertion failed in any run that took more than `REQUEST_BUDGET` seconds to reach it — which the full suite does, and CI would. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
Gravity PDF 7.0 removes the core font installer — the one-click downloader that pulled ~80 font files from a GitHub mirror into the uploads directory, and the admin nag that asked for it. In its place: a small set of fonts bundled with the plugin so a fresh install renders properly offline, and language packs installed on demand from our own update origin.
The installer is now gone. Its AJAX handler, its manifest, the React bundle behind the Tools-tab button, and the E2E stubs that stood in for the download have all been deleted, along with the render-time directory glob that quietly registered whatever it left behind.
Deleting the manifest is the interesting part.
build/payload/core-fonts.jsonwas the only record of what the installer could put on disk, so removing it would leave an upgraded site with a fonts directory nothing recognises — every PDF that named one of those fonts would fall back to the bundled one. The same change therefore freezes what the manifest knew: 39 mPDF font families over 70 files, each face carrying its filename, its size and the hash the manifest already held. The 7.0 upgrade reads that, verifies each file byte-for-byte, and re-registers what is actually there under the names 6.x used. A site rendering Korean throughunbatangkeeps rendering it, with no network and no template edits — even though a fresh install of the Korean language pack will ship a different face.Behind that, fonts moved out of the settings blob and into three tables read and written through one repository.
custom_fontsused to be an autoloaded option every WordPress request unserialised whether or not a PDF was generated, and every font write read-modify-wrote it in full, so a background install and a settings save could clobber each other. Writes are now row-level, the load path performs no filesystem operation at all, and one cached read sits behind both mPDF registration and the Font Manager, so what renders and what the UI lists cannot drift.Model_Custom_Fontsremains as a façade with its 6.x array shape intact, becauseGPDFAPI::get_pdf_fonts(),Helper_Data::customFontDataand third-party add-ons read that shape.What replaces the installer now exists too. A signed catalogue of font sources syncs from our own update origin; seventeen language packs install on demand from it, triggered by the site locale, by PDF settings, or by a render that meets a script it has no font for — that last one fetching inline, while the document draws, so a Japanese submission on a site with no Japanese pack produces a correct PDF rather than boxes. Installs run on their own background queue and report progress without touching the network. A health check notices a site whose fonts are wrong before its customers do, and the System Report carries the subsystem.
The catalogue's own integrity rules tightened along the way: an entry that omits the hash or size every download is checked against is now refused at sync, where a build mistake belongs, rather than surfacing mid-install on a customer's site. And a pack that renames a font key can now carry the old name with it, so a 6.x template naming the old one keeps rendering in the right face instead of silently falling back to the document font.
The Font Manager is rebuilt on
@wordpress/*packages: a catalogue browser with server-side search, per-entry install with style selection, an updates panel, and — for sites that cannot reach the font server at all — an offline import that takes a package zip, verifying every file against the catalogue row before anything is written. Multisite is handled throughout: fonts are network-global, and one site cannot delete the whole network's files.The Font Manager now reads nothing from fixtures. Its language settings were the last screen still backed by in-browser stand-ins; they save to the site now. An admin sets the language a PDF is assumed to be written in, the script it is typeset in, whether packs install themselves, and — row by row — which font should draw each language or script that is not the document's own. A single PDF can override the document language for itself.
Pinning the behaviour that table describes turned up two bugs older than this work. The document-script setting looked up a constant mPDF does not define, so every value it has ever been given was silently ignored and the script fell back to whatever the document language implied. And an unanswered dropdown on the PDF settings screen was stored as an empty array rather than an empty string — which is a
TypeErrorinstrtoupper( $settings['pdf_size'] )under PHP 8, and a literalfont-family: Arrayin the generated stylesheet.Try it
On a site that used the core font installer, upgrade to this branch, disconnect it from the internet, and load any admin page.
Every font the installer had written should be listed. Generate a PDF that uses one of them — it should render exactly as before. The Tools tab should no longer offer a "Download Core Fonts" button, and the nag asking for it should be gone.
On a site that never used the installer, a fresh install should generate the default template for an English entry with true bold and italic, and an embedded ✔ on a Consent field, with no fonts on disk and no outbound HTTP.
The Font Manager. Open any PDF's settings and click the button beside the Font field. The catalogue will not have synced yet, so Add new font → Browse Language packs should offer a Refresh rather than an empty grid; refresh it, then install a pack and watch the progress land file by file.
A render that installs what it needs. With the Japanese pack not installed, submit an entry containing Japanese text and generate its PDF. The glyphs should be there, not boxes.
The language settings. In the Font Manager, Language settings. Change the font for a language, add one the table does not list yet, save, then reopen the manager — both should have survived, and a row you reset should lose its reset button. Then set Default document language to Japanese on a site with the Japanese pack installed and a font of your own chosen for the PDF: Japanese text should render in your font, because it is the document's own language. Set it back to English and the pack takes those runs back.
The offline import. Add new font → Install from file takes a pack zip. On a site that has never synced there is no catalogue to verify against, so it should refuse with a 404 that says so and point at the offline install guide — that refusal is what the new E2E test covers.
Test plan
yarn test:php— 2,334 tests, 7,849 assertions, passing.yarn test:php:multisite— 7,963 assertions, passing.yarn test:js— 389 tests across 47 suites; coverage 89.6 / 85.1 / 87.3 / 89.4 against the 75% threshold.phpcs,lint:jsandlint:cssclean.Playwright: the
coreproject is 65 passed, 3 failed on a full run. Two of the three —multiple-pdfsand the license tab's invalid-key message — pass in isolation and failed only while three Jest coverage runs were competing for the machine. The third ispage-confirmation, which fails identically with this whole branch stashed: a timeout inside@wordpress/e2e-test-utils-playwright'ssetPreferences, unrelated to anything here. The Font Manager spec is now 13 tests and carries three Chromatic snapshots.This branch is rebased onto #1727, which unblocked
pdf-settings.spec.ts. Before that fix the spec failed 20 of its 21 tests here — every failure a 30 s timeout creating the form inbeforeEach, because 821 forms shared its title and Gravity Forms de-duplicates titles in O(n²) against the whole table. It now runs 21/21 in 1.5 minutes, against 10.3 minutes of timeouts before.One PHPUnit test is intermittently flaky under load:
Test_Render_Font_Trigger::test_a_japanese_submission_installs_the_pack_it_needs_before_it_draws, which failed once in three full runs and passes 9/9 when the class is run alone. Worth pinning down before release rather than at review time.yarn test:jspasses clean, but under--coveragea handful of component suites time out non-deterministically — different ones each run, several of them suites this branch never touched. Treat a coverage-run failure as machine load rather than signal; the coverage figures themselves are collected regardless.The adoption tests are not mocked. Four of the repo's existing PHPUnit font fixtures turn out to be byte-identical to their manifest entries, so they exercise the real hashes and independently corroborate the generated map. Neutering the verification fails two cases.
Two behaviour changes to note before release:
GPDFAPI::add_pdf_font().update_font().What's on the branch, and what's still to come
Landed here
GravityPDF/mpdf, carrying the font-registry decoupling), the two registry layers against it, release-build size, script-detector cost, the Noto replacements for the licensing-flagged fonts, the language-pack split, file hosting, and a dry run of the Google Fonts pipeline.fonts/adds 863 KB compressed; the zip is 5.16 MB against a 5.6 MB budget.@wordpress/*: catalogue browser, entry install with style selection, updates panel, language settings, previews, and the offline package import (POST /fonts/import) with its UI.GET/POST /fonts/settings, the four §4.8 keys, the per-PDFpdf_language, and the §4.4 behaviour table pinned against a real mPDF.Phase 1b — the tables
Font_Schemaownsgravitypdf_font,gravitypdf_font_fileand, on multisite,gravitypdf_font_site, created bydbDeltaand verified withSHOW TABLESafterwards. The stored version carries an-mssuffix once the site table exists, so a single site converted to a network after upgrading mismatches and creates it — anis_multisite()check behind an equal-version compare never would. A failed pass logsdbDelta's own messages and sets an hour-long transient, so a host that revokedCREATE TABLEretries hourly rather than per request.Font_Repository::all()is two statements, cached under alast_changedstamp every write bumps, memoised per request, and merged in PHP with this site's visibility toggles on multisite.verify()is the only pass that touches the disk, and it is onescandir()plus onefilesize()per distinct path, driven from the Font Manager and the health check rather than a render.Font_Migrationmatches on the set of files a record points at rather than the key derived from it, so a retried pass mints nothing and — on multisite, where the tables are network-global — the first site to migrate a key owns it.custom_fontsis deliberately left in place as a frozen snapshot: deleting it was the one irreversible step in the upgrade, and the rollback path needs it. 7.0 never reads it after migration and never writes it.ensure_ready()runs before the repository's first query on any request, gated by the autoloaded version option, because the upgrade routine only fires on an admin page load — a site that auto-updates and serves PDFs by REST or cron would otherwise render before it. Its mutating half runs under a network-level lock, and the version is written only after every step succeeds, so a failed pass is retried rather than closed by a version bump.Unlinking is rows-first everywhere. A file is removed only once no surviving file row records its path, because two installs of one entry can share files.
Phase 1c — the registry
Registrybuilds both mPDF package layers from one cached read. Order is load-bearing:FontRegistry::add()prepends and mPDF reads the result in that order, so the layer added last is read first. The bundled layer therefore goes in second, and itsbackupSubsFontentries sort ahead of the rows'.Helper_PDF::begin_pdf()builds the whole font config from the registry rather than from mPDF's built-in defaults, which the fork empties.autoScriptToLangis a 7.0 requirement rather than a preference — mPDF never substitutes Arabic or Indic glyphs, so a lang tag on the run is the only route to a font for those scripts.useKerninglikewise: Arimo carries no legacy kern table, so GPOS is its only route to kerning.tools/mu-plugins/mpdf.phpwas deleted here, and it mattered more than its size suggests: it mergedmode => 'c'into every test-suite mPDF construction, forcingonlyCoreFonts = true, which meant no test had ever proved a real font was embedded.Test_Bundled_Renderis the replacement guard, and it assertsonlyCoreFonts === falseso a re-introduced stub fails loudly.Phase 2 — deleting the installer
Legacy_Installer_Filesis grouped by font family rather than by file because that is how the fonts were registered:dejavusanscondensedis four files under one key, and adopting them as four rows would change the key a template resolves.use_otlanduse_kashidacome across too — they are what shaped the text in 6.x.The keys came from upstream mPDF's
FontVariablesat the revision before the font-package split. The fork's copy is empty, and its packages have replaced ten of these families outright, so no live source still has them.Verification uses the Git blob hash the manifest already carried, not a fresh SHA-256. A SHA-256 could only be derived by re-downloading 80 MB today, which would attest to that download rather than to what the installer shipped — weaker provenance, not stronger. It is checked by streaming, size first, so a 12 MB font is never read whole to reject it on length.
Font_Repositorygained aFont_Population_Passlist rather than a field and a null check per collaborator: migration, then legacy adoption, then loose import, in the order the bootstrap adds them. Ordering is a comment where the order is chosen. Phase 3 adds catalogue adoption and pack installs to the same list.Registry::mpdf_font_config()exists because two places construct mPDF —Helper_PDF::begin_pdf()and the v3mPDFshim indeprecated.php— and the shim had already drifted, missing the three keys 7.0 treats as requirements. Both now take the same array and merge their own per-document keys on top.Test_Deprecatedgained the first test that constructs the shim at all.Catalogue integrity — four recorded defects, three fixed and one withdrawn
These sit inside phases that already merged, so each is a fix on top of its phase rather than a change to what the phase specified. The full write-ups are §11 of the plan.
Fixed.
validate_entry()checked a file's name andremote_pathand stopped, leavingsha256andsize— the two fields the whole install-time security model rests on — unvalidated. Never exploitable, since the runtime fails closed either way, but it moved a build mistake from a refused index at sync onto a customer's site as afont_size_mismatchmid-install. Both are required now, andsizemust be a real integer rather than merely numeric: the published artefact emits one, and accepting"10"is accepting a build that has started stringifying its numbers.Registry::installed_package()passed an empty alias map, sofont-family: eeyekunicodein a 6.x template resolved to nothing after the 7.0 key renames — no error, no fallback notice, the PDF simply drawn in the document font. The same silent shape as GF 3.0 droppingRGForms. An entry now publishesaliasesasalias => font key, validated by the same tablelanguage_to_fontgoes through. The empty argument stays, and that is the fix rather than a shortcut: mPDF merges every package's aliases into onefonttrans, so only one list needs building and it has to be the one that sees every row — which is what enforces an alias deferring to a real font of the same name. The inversion sits beside the coverage maps rather than inside them, because a rename is something any source can do and Google has renamed a published family; gating it on coverage would have let a display family's rename pass validation and then be dropped at row-write time.sync_root()stamped onlylast_attemptwhen a source's content had not changed, sosyncedtracked the store's last publish rather than this site's last successful verification — and staleness readssynced. Thirty quiet days would have put the whole fleet on the retry backoff and raised the catalogue health check against a store answering perfectly, training every admin to ignore the one signal that would show a freeze attack. A verified root that still names the index a site holds now counts as a sync and clears the stale error line. The stamp sits below the changed-check, not above it: stamping first would mark a source fresh whose index then failed to arrive or parse, which is the same defect pointed the other way.Withdrawn. The fourth was to refuse an entry whose
remote_pathbasenames collide — the residue of five families'LICENSE.txtcollapsing to one file. Written, then taken back out: it fires on exactly the wrong entries.remote_pathonly builds a download URL, and the local name is thefilesmap key, which is already unique — so two licences ata/LICENSE.txtandb/LICENSE.txtunder distinct keys are lossless, and the rule refused them, while the entry the defect is actually about still validated, because the files the builder dropped left no trace to compare against. Since one bad entry refuses a whole source index, that is a third-party catalogue lost for a well-formed index. The invariant is only checkable where both sides of the collision still exist, which is the build pipeline.Verified against the live catalogue rather than fixtures alone: all seventeen published packs pass the tightened validation unchanged.
Phase 6 — the language settings
Rest_Font_Settingsserves the last entry in the client'sPENDING_ROUTES, so that array,mockOnly()andenablePendingRoutes()are all deleted:api/mock/is the Jest seam now and nothing else.Registry::language_map()is the screen's view of the two maps §4.4 already computes — one row per code in$default ∪ $effective, grouped the wayget_grouped_fonts()groups the sidebar so a label and an order can only be wrong in one place. It files a row under its default font rather than its current one, so a code moved elsewhere stays with the pack that claimed it and an admin can see what they changed it from. "Changed" stays derived (font !== default_font) rather than becoming a second fact that can disagree with the first, and the overrides are not sent back at all, for the same reason.Three deviations from what §4.4 and §4.8 specified, each recorded in the plan:
*on the wire in both directions, not''. The panel's font select carries a*option and no empty one, so an''would have drawn as "no selection" and saved back as whatever option happened to be first.Registry::LANGUAGE_NONEis the one spelling now, shared witheffective_language_map()and with the client throughconstants.js.document_scriptstores the bare script name (LATIN,HAN) — what the select offers.get_document_script()was looking upUcdn::LATIN, which mPDF does not define, so the setting had never once taken effect.Registry::script_constant()owns the vocabulary now: it folds mPDF'sSCRIPT_prefix either way, and the route refuses a name no constant answers to rather than storing aBANANAthat looks like a setting and does nothing.Registry::scripts()ships the offerable list so the client's third copy of it could be deleted.settings_sanitize()is tab-scoped and there is no settings export tool, while a section the sanitiser can reach has its unanswered fields blanked on every save of that tab, which forfont_language_overridesmeans an unrelated screen wipes the override map. Naming the section something no tab is a prefix of avoids that only by coincidence of strings: the tab comes from the referer with no whitelist, and the tab list is filterable.Rest_Font_Settingsis the only writer andRegistrythe only reader and defaulter. Per-PDFpdf_languageis registered, inform_settings_appearance, where the framework genuinely carries it — rendering, sanitising and a REST enum.Model_Form_Settings::settings_sanitize()no longer writes[]for an unanswered single-value select. It had since 6.x, fortemplate,pdf_size,orientationandfont.multicheckstill gets an array, and so does a select declaringmultiple, which core has none of and a third-party template may.Registry::setting()stays regardless — sites carry years of[]already on disk, and the reader is what makes those readable; it replaces the one-offis_string()guardCoverage_Resolverhad grown for the same reason.prune_language_overrides()lives onRegistrybesideeffective_language_map(), its read-side twin, rather than in the controller that posts to it: the upgrade routine, a repair pass and WP-CLI all have to be able to write a valid override map without restating the rule.default_language_map()is memoised on the repository stamp, since three callers of one request were each re-sorting every installed row to rebuild it.Language_To_Font::labels()is a table of ~190 language codes and ~59und-<script>ones — the union of every code a published pack claims, every code the frozen 6.x map carries, and the Latin-script languages the default-language select needs but no pack has to. It is memoised per locale, because one of its callers isget_registered_fields()and that runs on every REST request the site serves,GET /download/{entry}/{pdf}included; the locale key is what keeps a notification email rendering in the recipient's language rather than the site's. An unnamed code degrades to the raw code, and a test locks the one code set knowable in advance — every language a 6.x upgrade can adopt off disk.Test_Language_Behaviourpins §4.4's behaviour table against a real mPDF, asserting the fonts actually loaded rather than the map's own arithmetic: a Japanese document keeps its chosen font for Han and kana, the same document in English loses its Han to the installed pack,und-Hans → *is the way back, an explicitlangattribute reaches the map on any document, Vietnamese is not flipped out of the document font, and Cyrillic stays put in a font that carries it but is substituted from Arimo in one that does not.Deferred on purpose
The
entrypack linkage is not in the frozen map: the pack table is Phase 3a, and every adopted family lands as an imported row under its frozen 6.x key. That is the correct end state for an upgraded site regardless — the Noto swaps are only meant to reach fresh pack installs — so Phase 3a's catalogue adoption has nothing to undo.The
Health_Issueraised on a faileddbDeltagoes in with the rest of the health framework; thegfpdf_health_reportandgfpdf_font_catalog_rootuninstall lines go in with the features that create them.Still to come
The airgapped import path (§10), where a site with no catalogue verifies a package's detached signature instead. The first WP-CLI command,
wp gpdf fonts install <source>/<entry> [--from=<dir|zip>], which is the third offline route the docs page describes. Then Phase 7 — the distribution readme carrying thefonts.gravitypdf.comdisclosure, the privacy-policy content, the upgrade notice and changelog, the docs pass, and the add-on compatibility sweep.Known, and deliberately carried
Co-Authored-Bytrailers are incomplete, and by more than this description previously claimed. Two commits were named here as the only ones missing the trailer; both have since been fixed. An audit of the whole branch then found the real figure: 34 of the 89 commits carry no trailer, of which 18 aredocs:andbuild:and 16 are code — 14feat:and 2refactor:, a contiguous block dated 9–10 September that is precisely the Phase 3 work (the catalogue table and sync, the downloader, the install queue, the REST routes, the install triggers, the health check and System Report, and multisite ownership). They are left alone pending a decision on each, since a trailer is a claim about who wrote something and is not worth guessing at. The force-push constraint that once justified deferring this no longer applies — the rebase onto #1727 already did one. Thepackagecolumn's identity design has an alternative worth weighing before the first publish, written up at.claude/plans/2026-09-14-entry-identity-in-the-archive.md. And the store side owes two gates before display-family packages can ship, written up for hand-off in the update-server repo.Two obligations now sit with the pipeline rather than here. The
aliasesfield has a consumer waiting but nothing publishing it yet, so no pack can rename a key until it does. And the shared accept/reject corpus the pipeline's schema gate runs against (fonts/pipeline/expected/entry-schema.jsonin the update-server repo) needs the new hash and size cases before the next publish — that gate calls this branch'svalidate_entry()out of a Composer package, so the two will disagree until it does.