How to use font metadata for design-system version tracking
- Step 1Establish the baseline — On the version you currently ship, run each font through the extractor and record the Version (nameID 5), `num_glyphs`, `units_per_em`, and `outlines_format`. Commit these to an `expected.json` next to your font assets. Also record the SHA-256 from the [Font Fingerprinter](/font-tools/font-fingerprinter).
- Step 2Re-extract on every font update — Whenever a font is replaced — a dependency bump, a CDN refresh, a designer drop — re-run the extractor and compare the new Version string and glyph count against the baseline. The tool decompresses WOFF2/WOFF, so you compare the exact artefact you serve.
- Step 3Treat the version string as opaque — Don't parse semantics out of nameID 5 — `Version 4.000`, `Version 1.001;git-abc`, and `OTF 1.020;PS 001.000` are all valid. Compare it as a whole string against the expected value; any difference is a signal to investigate.
- Step 4Back the version with a fingerprint — Because some foundries update without bumping nameID 5, a matching version string isn't a guarantee. Compare the SHA-256 too: identical version + changed hash means a silent update; investigate the rendering impact.
- Step 5Wire it into CI as a gate — Replicate the opentype.js extraction in a build step (see [the CI extraction guide](/font-tools/guides/metadata-extraction-script)), diff the output against the committed `expected.json`, and fail the build on unexpected change. An intentional bump is then a deliberate edit to `expected.json` in the same PR.
- Step 6Document the pinned versions — Record the pinned set in your design-system docs ('Inter 4.000, Roboto 3.011'). Update on intentional bumps; let the CI gate catch the unintentional ones. Pair a visual-regression run on intentional bumps to see what actually changed on screen.
Fields that detect drift
The extractor fields most useful for catching font changes between builds, and the kind of drift each one catches.
| Field | Source | Drift it catches |
|---|---|---|
| Version (nameID 5) | name table | Explicit foundry version bumps — when the foundry bothers to bump it |
num_glyphs | maxp | Added or removed glyphs (new language coverage, dropped alternates) even if the version string didn't move |
units_per_em | head | A re-scaled design grid (rare but breaks metrics if it changes) |
outlines_format | opentype.js | A switch between TrueType and CFF outlines between releases |
tables_present | sfnt directory | Added/removed capabilities — a new COLR, fvar, or a dropped kern/GPOS |
| SHA-256 (Fingerprinter) | Whole-file hash | Any byte change at all — the catch-all for updates the version string hides |
Version string formats you'll encounter
nameID 5 is free text and foundries don't agree on a format. Treat it as an opaque identifier and compare whole strings.
| Example Version string | Source style | How to treat it |
|---|---|---|
Version 4.000 | Common open-source | Compare verbatim; reliable when the foundry maintains it |
Version 1.001;git-7c0e... | Build-stamped (git hash) | The hash makes it tamper-evident-ish, but still a string — compare whole |
Version 3.011; ttfautohint (v1.8.4) | Tooling-stamped | Tool version can change without design change — corroborate with fingerprint |
OTF 1.020;PS 001.000;Core 1.0.38 | Adobe-style | Multiple sub-versions; compare the full string |
| (missing) | Stripped/never set | Rely on num_glyphs + SHA-256 instead |
Per-file limits
Size ceiling is checked on the uploaded compressed file. One font per run.
| Tier | Per-file limit | Batch |
|---|---|---|
| Free | 5 MB | 1 file at a time |
| Pro | 50 MB | 1 file at a time |
| Developer | 1 GB | 1 file at a time |
Cookbook
Concrete drift scenarios and how the extractor (plus a fingerprint) surfaces them. To run these checks automatically on every build, see the CI extraction guide.
Clean version bump caught at build
ExampleThe straightforward case: the foundry bumped nameID 5 and your CI gate flags it. The fix is a deliberate edit to expected.json, ideally with a visual-regression pass in the same PR.
expected.json: "Inter": { "version": "Version 4.000", "num_glyphs": 2548 }
extracted now: Version 4.001, num_glyphs 2561
CI diff:
Inter: version 4.000 -> 4.001 (+13 glyphs)
=> build fails: acknowledge the bump in this PR
(update expected.json) and run visual regressionSilent update: same version, different bytes
ExampleThe trap version-only tracking misses. The foundry shipped a fix without bumping nameID 5 — the version string matches, but the SHA-256 changed. Only the fingerprint catches it.
Version (nameID 5): Version 3.011 (unchanged) num_glyphs: 1294 (unchanged) SHA-256: a91f... -> 4c7e... (CHANGED) Verdict: silent update. The kerning or an outline likely changed. Investigate rendering; re-baseline both version and hash.
Glyph-count drift the version hid
ExampleSometimes the version is static but coverage changed. num_glyphs is the cheap tell — new glyphs (extra language support) or removed ones (an over-eager subset) show up immediately.
Build A: num_glyphs 880 Build B: num_glyphs 642 (same Version string) Verdict: 238 glyphs vanished between builds. Likely a subsetting step changed. Confirm coverage with /font-tools/character-coverage-map and glyph totals with /font-tools/glyph-count-analyzer
Capability change in tables_present
ExampleA foundry can add or drop whole tables between releases. tables_present makes that visible — a newly-added COLR means the font gained colour layers; a dropped kern changes spacing.
Build A tables_present: [..., "GPOS", "GSUB", "kern", ...] Build B tables_present: [..., "GPOS", "GSUB", ...] (kern gone) Verdict: legacy kern table dropped — spacing now relies on GPOS only. Audit kerning with /font-tools/kerning-pair-auditor
expected.json pinning shape
ExampleThe committed baseline a CI gate diffs against. Keep both the version string and the fingerprint so you catch both explicit and silent updates.
{
"Inter-Regular.woff2": {
"version": "Version 4.000",
"num_glyphs": 2548,
"units_per_em": 2048,
"sha256": "a91f3c..."
},
"Roboto-Regular.woff2": {
"version": "Version 3.011",
"num_glyphs": 1294,
"sha256": "4c7e88..."
}
}Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Foundry updates the font without bumping nameID 5
Silent updateGoogle Fonts and some others ship fixes continuously without changing the embedded Version string. Version-only tracking passes a changed font as unchanged. The defence is a SHA-256 from the Font Fingerprinter: identical version + different hash = silent update. Always pin both.
Version string changes but rendering doesn't
ExpectedThe reverse case: a foundry bumps nameID 5 for a metadata-only change (a corrected copyright year, a re-run of ttfautohint) that doesn't alter outlines. The version diff fires but visual regression shows nothing. That's fine — the gate did its job; you acknowledge the bump and move on. The point is that nothing changes silently.
Version string format is inconsistent across your fonts
Opaque stringDifferent foundries write nameID 5 differently. Don't try to parse semver out of it — store and compare the whole string. A change in any part (including a trailing tool version) is a signal to look closer, not an error in the extractor.
Same family, two outline formats
ExpectedIf a family ships both a TrueType and a CFF build, outlines_format and units_per_em differ between them even at the same nominal version. Pin each artefact you actually ship by filename — don't assume one entry in expected.json covers both builds.
Variable font instance vs the variable file
ExpectedIf part of your pipeline freezes a variable font to a static instance with the Variable Font Freezer, the frozen output is a new binary with its own glyph count and fingerprint, and tables_present loses fvar. Track the frozen artefacts separately from the source variable font.
Version missing entirely
Use other signalsSome fonts (especially heavily subset webfonts) drop nameID 5. The version field then doesn't appear in the JSON. Fall back to num_glyphs + SHA-256 for the pin — the fingerprint is the most reliable identity when the metadata is thin.
Subsetting changes num_glyphs every build
ReviewIf your build dynamically subsets fonts to the glyphs a page uses, num_glyphs (and the fingerprint) will change every build by design, making them noisy as drift signals. In that case pin the source font's metadata pre-subset, and track the subset step's inputs/config rather than its output binary.
CDN serves a different file than your repo
EscalateSupply-chain drift: your repo's WOFF2 and the one the CDN actually serves can diverge (a cache poisoning, a manual CDN edit). Extract and fingerprint the live CDN artefact, not just the repo copy, and compare both against expected.json.
units_per_em changed between releases
EscalateRare, but a foundry re-scaling the em from 1000 to 2048 (or vice versa) changes how every metric maps to pixels. The extractor surfaces units_per_em; a change here warrants careful review of line-height and vertical-metric assumptions across the design system.
Frequently asked questions
What's a typical version format?
There isn't one. You'll see Version 4.000, Version 1.001;git-..., OTF 1.020;PS 001.000, and tool-stamped strings like Version 3.011; ttfautohint (v1.8.4). The extractor returns nameID 5 verbatim — treat it as an opaque identifier and compare whole strings rather than parsing it as semver.
Why does version matter for a design system?
A point release can re-kern, redraw glyphs, change metrics, or add/drop OpenType features — all of which reflow or restyle your UI without any change to your code. Pinning the version (and verifying it on every build) turns a silent visual change into a tracked, reviewable event.
What about Google Fonts versions?
Google updates fonts continuously and often without bumping the public Version string in nameID 5. So for Google Fonts, version tracking alone is unreliable — pair it with a SHA-256 from the Font Fingerprinter, which detects any byte change regardless of whether the version string moved.
Is version or fingerprint the better pin?
Use both. The version string is human-readable and tells you intent ('the foundry called this 4.001'); the SHA-256 is exact and catches silent updates the version string hides. Version answers 'which release?'; the hash answers 'is this the exact byte sequence I approved?'
Can the extractor catch a changed glyph count?
Yes — num_glyphs is in the output. If a font gains language coverage or an over-eager subset drops alternates, the glyph count moves even when the version string doesn't. It's a cheap, reliable drift signal alongside the version and the fingerprint.
How do I detect a dropped or added OpenType table?
Compare tables_present between builds. A new COLR/CPAL means the font gained colour; a dropped kern means spacing now relies on GPOS only; a new fvar means it became variable. For the actual feature set use the OpenType Features Inspector.
Can I run this in CI?
The browser tool is interactive, but the underlying extraction is plain opentype.js — replicate it in a Node build step (see the CI extraction guide) to emit JSON per font and diff against a committed expected.json. Or pair the @jadapps/runner to drive the tool locally without uploading fonts.
Does the version string prove the font wasn't tampered with?
No. nameID 5 is editable text — anyone can set it to anything. For tamper evidence use the SHA-256 fingerprint, which changes if a single byte changes. The version string tells you what the font claims to be; the hash tells you whether it actually is the file you approved.
What if two fonts share a version but render differently?
That usually means a silent update (same nameID 5, different bytes) or two genuinely different fonts that happen to use the same version string. Compare the SHA-256 and num_glyphs; if they differ, the fonts differ regardless of the version string.
Should I commit the extracted metadata?
Yes — a committed expected.json (version, glyph count, units-per-em, SHA-256 per font) is the baseline your CI gate diffs against and a manifest of what shipped. It's also useful long after the fact for compliance and incident review. An intentional bump becomes a reviewable change to that file.
Does freezing a variable font change the tracking?
Yes — a frozen instance from the Variable Font Freezer is a new binary with its own glyph count, fingerprint, and a tables_present list that no longer contains fvar. Track frozen artefacts as their own entries separate from the source variable font.
Will the extractor flag drift automatically?
The tool itself just reports the current metadata; the flagging is your diff against expected.json. Wire that diff into CI to get an automatic gate. The extractor's job is to produce a stable, comparable JSON shape on every run, which it does — same fields, every time.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.