Skip to content

The 7.0 font subsystem: remove the core installer, add language packs and the new Font Manager - #1722

Draft
jakejackson1 wants to merge 99 commits into
developmentfrom
feature/remove-core-font-installer
Draft

jakejackson1 wants to merge 99 commits into
developmentfrom
feature/remove-core-font-installer

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Sep 7, 2026

Copy link
Copy Markdown
Member

Draft, tracking branch. This is the long-running 7.0 font epic, not a finished change. It is here to make the work visible and reviewable in pieces; the plan splits it into separate PRs and this branch carries them together. Not for merge in its present shape.

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.json was 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 through unbatang keeps 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_fonts used 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_Fonts remains as a façade with its 6.x array shape intact, because GPDFAPI::get_pdf_fonts(), Helper_Data::customFontData and 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 TypeError in strtoupper( $settings['pdf_size'] ) under PHP 8, and a literal font-family: Array in 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.

wp option get gfpdf_db_version
wp db query "SELECT font_key, label, source FROM wp_gravitypdf_font"

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:php2,334 tests, 7,849 assertions, passing. yarn test:php:multisite7,963 assertions, passing. yarn test:js389 tests across 47 suites; coverage 89.6 / 85.1 / 87.3 / 89.4 against the 75% threshold. phpcs, lint:js and lint:css clean.

Playwright: the core project is 65 passed, 3 failed on a full run. Two of the three — multiple-pdfs and 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 is page-confirmation, which fails identically with this whole branch stashed: a timeout inside @wordpress/e2e-test-utils-playwright's setPreferences, 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 in beforeEach, 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:js passes clean, but under --coverage a 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:

  • Fonts dropped straight into the uploads fonts directory are no longer registered on every render. Existing ones are imported into the Font Manager during the upgrade, keeping the name templates use; new ones must go through the Font Manager or GPDFAPI::add_pdf_font().
  • The tables have columns, so a key outside the documented 6.x shape no longer survives a round trip through update_font().
What's on the branch, and what's still to come

Landed here

  • Phase 0 spikes. The mPDF fork (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.
  • Bundled fonts. Four dehinted Arimo statics plus a DejaVu symbol subset, at the plugin root. fonts/ adds 863 KB compressed; the zip is 5.16 MB against a 5.6 MB budget.
  • Phase 1a. mPDF switched to the fork.
  • Phase 1b. The font tables.
  • Phase 1c. The registry, the package layers, the loose-font importer.
  • Phase 2. Deleting the installer.
  • Phase 3. The catalogue table and source registry, the repository, the signed sync and its pinned key, the streaming downloader, the install queue, the REST routes, the install triggers (locale, settings, and inline-at-render), the health check and System Report section, and multisite ownership.
  • Phases 4–5. The Font Manager rebuilt on @wordpress/*: catalogue browser, entry install with style selection, updates panel, language settings, previews, and the offline package import (POST /fonts/import) with its UI.
  • Phase 6. The language settings served for real: GET/POST /fonts/settings, the four §4.8 keys, the per-PDF pdf_language, and the §4.4 behaviour table pinned against a real mPDF.

Phase 1b — the tables

Font_Schema owns gravitypdf_font, gravitypdf_font_file and, on multisite, gravitypdf_font_site, created by dbDelta and verified with SHOW TABLES afterwards. The stored version carries an -ms suffix once the site table exists, so a single site converted to a network after upgrading mismatches and creates it — an is_multisite() check behind an equal-version compare never would. A failed pass logs dbDelta's own messages and sets an hour-long transient, so a host that revoked CREATE TABLE retries hourly rather than per request.

Font_Repository::all() is two statements, cached under a last_changed stamp 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 one scandir() plus one filesize() per distinct path, driven from the Font Manager and the health check rather than a render.

Font_Migration matches 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_fonts is 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

Registry builds 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 its backupSubsFont entries 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. autoScriptToLang is 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. useKerning likewise: Arimo carries no legacy kern table, so GPOS is its only route to kerning.

tools/mu-plugins/mpdf.php was deleted here, and it mattered more than its size suggests: it merged mode => 'c' into every test-suite mPDF construction, forcing onlyCoreFonts = true, which meant no test had ever proved a real font was embedded. Test_Bundled_Render is the replacement guard, and it asserts onlyCoreFonts === false so a re-introduced stub fails loudly.

Phase 2 — deleting the installer

Legacy_Installer_Files is grouped by font family rather than by file because that is how the fonts were registered: dejavusanscondensed is four files under one key, and adopting them as four rows would change the key a template resolves. use_otl and use_kashida come across too — they are what shaped the text in 6.x.

The keys came from upstream mPDF's FontVariables at 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_Repository gained a Font_Population_Pass list 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 v3 mPDF shim in deprecated.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_Deprecated gained 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 and remote_path and stopped, leaving sha256 and size — 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 a font_size_mismatch mid-install. Both are required now, and size must 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, 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 drawn in the document font. The same silent shape as GF 3.0 dropping RGForms. An entry now publishes aliases as alias => font key, validated by the same table language_to_font goes through. The empty argument stays, and that is the fix rather than a shortcut: mPDF merges every package's aliases into one fonttrans, 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 only last_attempt when a source's content had not changed, so synced tracked the store's last publish rather than this site's last successful verification — and staleness reads synced. 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_path basenames collide — the residue of five families' LICENSE.txt collapsing to one file. Written, then taken back out: it fires on exactly the wrong entries. remote_path only builds a download URL, and the local name is the files map key, which is already unique — so two licences at a/LICENSE.txt and b/LICENSE.txt under 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_Settings serves the last entry in the client's PENDING_ROUTES, so that array, mockOnly() and enablePendingRoutes() 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 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, 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:

  • The sentinel is * 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_NONE is the one spelling now, shared with effective_language_map() and with the client through constants.js.
  • document_script stores the bare script name (LATIN, HAN) — what the select offers. get_document_script() was looking up Ucdn::LATIN, which mPDF does not define, so the setting had never once taken effect. Registry::script_constant() owns the vocabulary now: 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 of it could be deleted.
  • The four keys are deliberately not registered as settings fields. §4.8 asked for that so they would be "sanitised/exported with the rest" — 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 on every save of that tab, which for font_language_overrides means 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_Settings is the only writer and Registry the only reader and defaulter. Per-PDF pdf_language is registered, in form_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, for template, pdf_size, orientation and font. multicheck still gets an array, and so does a select declaring multiple, 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-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 rebuild it.

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-language 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; 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_Behaviour pins §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 explicit lang attribute 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 entry pack 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_Issue raised on a failed dbDelta goes in with the rest of the health framework; the gfpdf_health_report and gfpdf_font_catalog_root uninstall 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 the fonts.gravitypdf.com disclosure, the privacy-policy content, the upgrade notice and changelog, the docs pass, and the add-on compatibility sweep.

Known, and deliberately carried

Co-Authored-By trailers 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 are docs: and build: and 16 are code — 14 feat: and 2 refactor:, 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. The package column'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 aliases field 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.json in the update-server repo) needs the new hash and size cases before the next publish — that gate calls this branch's validate_entry() out of a Composer package, so the two will disagree until it does.

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Coverage report for commit: d66c4d3
File: ./tmp/jest-coverage/clover.xml

Cover ┌─────────────────────────┐ Freq.
   0% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  10% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  20% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  30% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  40% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  50% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  60% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  70% │ █░░░░░░░░░░░░░░░░░░░░░░ │  1.8%
  80% │ ████░░░░░░░░░░░░░░░░░░░ │  9.1%
  90% │ ████████░░░░░░░░░░░░░░░ │ 21.8%
 100% │ ███████████████████████ │ 67.3%
      └─────────────────────────┘
 *Legend:* █ = Current Distribution 
Summary - Lines: 92.34% | Methods: 86.94% | Branches: 80.61%
FilesLinesMethodsBranches
src/assets/js/react/actions
   fontManager.js100.00%100.00%100.00%
   templates.js100.00%100.00%100.00%
src/assets/js/react/components/Alert
   Alert.js100.00%100.00%100.00%
src/assets/js/react/components
   CustomHashRouter.js100.00%100.00%100.00%
   Empty.js100.00%100.00%100.00%
   ShowMessage.js79.31%80.00%64.29%
   Spinner.js100.00%100.00%100.00%
src/assets/js/react/components/FontManager
   AddFont.js100.00%100.00%100.00%
   AddUpdateFontFooter.js85.37%50.00%88.89%
   AdvancedButton.js100.00%100.00%100.00%
   FontList.js100.00%50.00%65.22%
   FontListAlertMessage.js100.00%100.00%100.00%
   FontListHeader.js100.00%100.00%100.00%
   FontListIcon.js100.00%100.00%100.00%
   FontListItems.js85.39%64.00%68.66%
   FontListSkeleton.js100.00%100.00%100.00%
   FontManager.js77.78%57.14%50.00%
   FontManagerBody.js94.20%96.43%90.29%
   FontManagerHeader.js100.00%100.00%100.00%
   FontVariant.js90.00%60.00%70.00%
   FontVariantLabel.js100.00%100.00%100.00%
   InitialAddUpdateState.js100.00%100.00%100.00%
   SearchBox.js90.00%66.67%69.23%
   TemplateTooltip.js100.00%75.00%100.00%
   UpdateFont.js75.00%50.00%75.00%
src/assets/js/react/components/Modal
   CloseDialog.js93.33%66.67%70.59%
src/assets/js/react/components/Template
   TemplateActivateButton.js100.00%100.00%100.00%
   TemplateButton.js85.71%66.67%100.00%
   TemplateContainer.js71.43%66.67%25.00%
   TemplateDeleteButton.js100.00%100.00%70.00%
   TemplateFooterActions.js100.00%100.00%100.00%
   TemplateHeaderNavigation.js82.35%85.71%70.00%
   TemplateHeaderTitle.js100.00%100.00%100.00%
   TemplateList.js100.00%100.00%60.00%
   TemplateListItem.js100.00%100.00%92.86%
   TemplateListItemComponents.js100.00%100.00%100.00%
   TemplateScreenshot.js100.00%100.00%100.00%
   TemplateScreenshots.js100.00%100.00%50.00%
   TemplateSearch.js93.75%88.89%50.00%
   TemplateSingle.js100.00%100.00%100.00%
   TemplateSingleComponents.js100.00%100.00%75.00%
   TemplateUploader.js98.04%100.00%86.67%
src/assets/js/react/reducers
   fontManagerReducer.js87.21%75.00%75.00%
   index.js100.00%100.00%100.00%
   templateReducer.js100.00%100.00%100.00%
src/assets/js/react/sagas
   fontManager.js86.96%90.00%83.33%
   index.js100.00%100.00%100.00%
   templates.js83.33%100.00%100.00%
src/assets/js/react/selectors
   getTemplates.js91.11%100.00%83.33%
src/assets/js/react/utilities/FontManager
   adjustFontListHeight.js100.00%100.00%100.00%
   associatedFontManagerSelectBox.js94.44%100.00%66.67%
   fontManagerReducer.js100.00%100.00%100.00%
   getTabLocation.js100.00%100.00%100.00%
   toggleUpdateFont.js100.00%100.00%100.00%
src/assets/js/react/utilities
   withRouterHooks.js100.00%100.00%100.00%

🤖 Jest coverage report

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

Coverage report for commit: d66c4d3
File: tmp/coverage/report-xml/merged.xml

Cover ┌─────────────────────────┐ Freq.
   0% │ ████████░░░░░░░░░░░░░░░ │ 11.1%
  10% │ ░░░░░░░░░░░░░░░░░░░░░░░ │  0.0%
  20% │ █░░░░░░░░░░░░░░░░░░░░░░ │  0.5%
  30% │ █░░░░░░░░░░░░░░░░░░░░░░ │  0.9%
  40% │ █░░░░░░░░░░░░░░░░░░░░░░ │  0.5%
  50% │ ███████░░░░░░░░░░░░░░░░ │  8.8%
  60% │ █░░░░░░░░░░░░░░░░░░░░░░ │  1.4%
  70% │ ████░░░░░░░░░░░░░░░░░░░ │  4.6%
  80% │ █████████████░░░░░░░░░░ │ 18.1%
  90% │ ███████████████████████ │ 32.9%
 100% │ ███████████████░░░░░░░░ │ 21.3%
      └─────────────────────────┘
 *Legend:* █ = Current Distribution 
Summary - Lines: 85.28% | Methods: 93.32%
FilesLinesMethodsBranches
/var/www/html/wp-content/plugins/gravity-pdf
   api.php96.67%100.00%100.00%
   gravity-pdf-updater.php53.97%100.00%100.00%
   pdf.php59.82%81.25%100.00%
/var/www/html/wp-content/plugins/gravity-pdf/src/Controller
   Controller_Actions.php98.81%100.00%100.00%
   Controller_Activation.php95.45%100.00%100.00%
   Controller_Custom_Fonts.php88.46%100.00%100.00%
   Controller_Debug.php100.00%100.00%100.00%
   Controller_Export_Entries.php96.67%100.00%100.00%
   Controller_Form_Settings.php86.05%90.00%100.00%
   Controller_Install.php100.00%100.00%100.00%
   Controller_Mergetags.php100.00%100.00%100.00%
   Controller_PDF.php78.71%100.00%100.00%
   Controller_Pdf_Queue.php85.04%87.50%100.00%
   Controller_Settings.php86.05%100.00%100.00%
   Controller_Shortcodes.php100.00%100.00%100.00%
   Controller_System_Report.php100.00%100.00%100.00%
   Controller_Templates.php100.00%100.00%100.00%
   Controller_Uninstaller.php83.33%77.78%100.00%
   Controller_Upgrade_Routines.php94.12%100.00%100.00%
   Controller_Webhooks.php100.00%100.00%100.00%
   Controller_Zapier.php100.00%100.00%100.00%
/var/www/html/wp-content/plugins/gravity-pdf/src/Exceptions
   GravityPdfDatabaseUpdateException.php50.00%100.00%100.00%
   GravityPdfDomainException.php50.00%100.00%100.00%
   GravityPdfException.php50.00%100.00%100.00%
   GravityPdfFontNotFoundException.php50.00%100.00%100.00%
   GravityPdfIdException.php50.00%100.00%100.00%
   GravityPdfModelNotUpdatedException.php50.00%100.00%100.00%
   GravityPdfRuntimeException.php50.00%100.00%100.00%
   GravityPdfShortcodeEntryIdException.php50.00%100.00%100.00%
   GravityPdfShortcodePdfConditionalLogicFailedException.php50.00%100.00%100.00%
   GravityPdfShortcodePdfConfigNotFoundException.php50.00%100.00%100.00%
   GravityPdfShortcodePdfInactiveException.php50.00%100.00%100.00%
/var/www/html/wp-content/plugins/gravity-pdf/src/Fonts
   FlushCache.php80.00%100.00%100.00%
   Font_Lock.php50.00%100.00%100.00%
   Font_Migration.php96.49%100.00%100.00%
   Font_Population_Pass.php-100.00%100.00%
   Font_Repository.php86.83%92.59%100.00%
   Font_Schema.php84.21%93.75%100.00%
   Language_To_Font.php88.24%100.00%100.00%
   Legacy_Font_Adopter.php98.21%100.00%100.00%
   Legacy_Installer_Files.php93.75%100.00%100.00%
   LocalFile.php90.00%100.00%100.00%
   LocalFilesystem.php66.67%100.00%100.00%
   Loose_Font_Importer.php93.07%100.00%100.00%
   Package.php94.74%100.00%100.00%
   Registry.php96.22%94.12%100.00%
   SupportsOtl.php100.00%100.00%100.00%
   TtfFontValidation.php81.82%100.00%100.00%
/var/www/html/wp-content/plugins/gravity-pdf/src/Helper/Fields
   Field_Address.php92.16%100.00%100.00%
   Field_Chainedselect.php66.67%75.00%100.00%
   Field_Checkbox.php94.34%100.00%100.00%
   Field_Consent.php90.00%100.00%100.00%
   Field_Coupon.php--100.00%
   Field_Creditcard.php83.33%100.00%100.00%
   Field_Date.php83.33%100.00%100.00%
   Field_Default.php83.33%100.00%100.00%
   Field_Discount.php44.00%75.00%100.00%
   Field_Email.php83.33%100.00%100.00%
   Field_Fg_Ls_Consent.php92.86%100.00%100.00%
   Field_Fg_Ls_Signature.php73.08%66.67%100.00%
   Field_Fileupload.php94.23%100.00%100.00%
   Field_Form.php89.09%100.00%100.00%
   Field_Hidden.php81.82%100.00%100.00%
   Field_Html.php89.47%100.00%100.00%
   Field_Image_Choice.php86.67%100.00%100.00%
   Field_Likert.php97.22%100.00%100.00%
   Field_List.php92.41%100.00%100.00%
   Field_Multi_Choice.php50.00%100.00%100.00%
   Field_Multiselect.php92.59%100.00%100.00%
   Field_Name.php84.62%100.00%100.00%
   Field_Number.php83.33%100.00%100.00%
   Field_Option.php57.69%50.00%100.00%
   Field_Page.php83.33%100.00%100.00%
   Field_Phone.php94.55%100.00%100.00%
   Field_Poll.php93.75%100.00%100.00%
   Field_Post_Category.php85.00%100.00%100.00%
   Field_Post_Content.php82.35%100.00%100.00%
   Field_Post_Custom_Field.php50.00%100.00%100.00%
   Field_Post_Excerpt.php81.82%100.00%100.00%
   Field_Post_Image.php94.00%100.00%100.00%
   Field_Post_Tags.php90.91%100.00%100.00%
   Field_Post_Title.php81.82%100.00%100.00%
   Field_Product.php88.46%100.00%100.00%
   Field_Products.php84.32%100.00%100.00%
   Field_Quantity.php84.62%100.00%100.00%
   Field_Quiz.php89.74%100.00%100.00%
   Field_Radio.php95.35%100.00%100.00%
   Field_Rank.php95.24%100.00%100.00%
   Field_Rating.php95.24%100.00%100.00%
   Field_Repeater.php97.37%100.00%100.00%
   Field_Section.php90.74%100.00%100.00%
   Field_Select.php94.12%100.00%100.00%
   Field_Shipping.php75.00%66.67%100.00%
   Field_Signature.php68.89%100.00%100.00%
   Field_Slim.php82.35%100.00%100.00%
   Field_Slim_Post.php91.30%100.00%100.00%
   Field_Subtotal.php65.38%75.00%100.00%
   Field_Survey.php95.00%100.00%100.00%
   Field_Tax.php32.00%50.00%100.00%
   Field_Text.php81.82%100.00%100.00%
   Field_Textarea.php90.63%100.00%100.00%
   Field_Time.php81.82%100.00%100.00%
   Field_Tos.php92.86%100.00%100.00%
   Field_Total.php68.00%66.67%100.00%
   Field_Website.php85.71%100.00%100.00%
/var/www/html/wp-content/plugins/gravity-pdf/src/Helper
   Helper_Abstract_Addon.php93.33%100.00%100.00%
   Helper_Abstract_Config_Settings.php75.00%100.00%100.00%
   Helper_Abstract_Controller.php-100.00%100.00%
   Helper_Abstract_Field_Products.php89.47%66.67%100.00%
   Helper_Abstract_Fields.php93.59%93.75%100.00%
   Helper_Abstract_Fields_Input_Type.php84.00%100.00%100.00%
   Helper_Abstract_Form.php--100.00%
   Helper_Abstract_Model.php100.00%100.00%100.00%
   Helper_Abstract_Options.php74.54%78.57%100.00%
   Helper_Abstract_Pdf_Shortcode.php89.52%91.67%100.00%
   Helper_Abstract_View.php96.77%100.00%100.00%
   Helper_Data.php96.47%100.00%100.00%
   Helper_Field_Container.php92.31%100.00%100.00%
   Helper_Field_Container_Gf25.php82.22%100.00%100.00%
   Helper_Field_Container_Void.php16.67%-100.00%
   Helper_Form.php70.97%64.29%100.00%
   Helper_Interface_Actions.php-100.00%100.00%
   Helper_Interface_Config.php50.00%100.00%100.00%
   Helper_Interface_Config_Settings.php50.00%100.00%100.00%
   Helper_Interface_Deprecated_Features.php-100.00%100.00%
   Helper_Interface_Extension_Settings.php-100.00%100.00%
   Helper_Interface_Extension_Uninstaller.php-100.00%100.00%
   Helper_Interface_Field_Pdf_Config.php50.00%100.00%100.00%
   Helper_Interface_Filters.php-100.00%100.00%
   Helper_Interface_Setup_TearDown.php-100.00%100.00%
   Helper_Interface_Url_Signer.php-100.00%100.00%
   Helper_Logger.php-100.00%100.00%
   Helper_Misc.php71.34%96.55%100.00%
   Helper_Mpdf.php50.00%100.00%100.00%
   Helper_Notices.php89.47%100.00%100.00%
   Helper_Options_Fields.php98.02%100.00%100.00%
   Helper_PDF.php87.40%94.29%100.00%
   Helper_PDF_List_Table.php84.50%92.31%100.00%
   Helper_Pdf_Queue.php84.31%100.00%100.00%
   Helper_QueryPath.php80.00%100.00%100.00%
   Helper_Sha256_Url_Signer.php87.50%100.00%100.00%
   Helper_Singleton.php90.00%100.00%100.00%
   Helper_Templates.php96.25%100.00%100.00%
Table truncated to fit comment

🤖 PHPUnit coverage report

@jakejackson1
jakejackson1 force-pushed the feature/remove-core-font-installer branch 3 times, most recently from 7d5350b to d6324b6 Compare September 7, 2026 12:13
/** @var \GFPDF\Model\Model_Actions $model */
$model = \GPDFAPI::get_mvc_class( 'Model_Actions' );

$model->undismiss_notices( [ 'install_core_fonts' ] );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Don't think it could be dismissed.

@jakejackson1
jakejackson1 force-pushed the feature/remove-core-font-installer branch 6 times, most recently from 44c9b59 to d66c4d3 Compare September 8, 2026 20:52
@socket-security

socket-security Bot commented Sep 14, 2026

Copy link
Copy Markdown

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.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: npm date-fns is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: yarn.locknpm/@wordpress/components@29.5.4npm/date-fns@3.6.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/date-fns@3.6.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: npm date-fns is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: yarn.locknpm/@wordpress/components@29.5.4npm/date-fns@3.6.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore npm/date-fns@3.6.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

@jakejackson1 jakejackson1 changed the title Remove the core font installer (7.0) The 7.0 font subsystem: remove the core installer, add language packs and the new Font Manager Sep 14, 2026
jakejackson1 and others added 14 commits September 15, 2026 09:57
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>
jakejackson1 and others added 19 commits September 15, 2026 11:03
`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>
@jakejackson1
jakejackson1 force-pushed the feature/remove-core-font-installer branch from 0d68f57 to 63362ca Compare September 15, 2026 01:04
jakejackson1 and others added 10 commits September 15, 2026 11:42
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant