How to generate coverage reports for every font in your design system
- Step 1Walk the fonts directory — `fs.readdirSync('src/fonts')`, filter to `.ttf/.otf/.woff/.woff2`. opentype.js parses TTF/OTF directly; for WOFF/WOFF2 decompress to sfnt first (wawoff2 for WOFF2, pako/zlib for WOFF), mirroring what the browser tool does.
- Step 2Collect every codepoint per font — For each glyph index, read `glyph.unicode` and iterate `glyph.unicodes`, adding each to a `Set<number>`. This is exactly what the hosted tool does — encoded codepoints only, so unencoded alternates are correctly excluded.
- Step 3Bucket codepoints into Unicode blocks — Hold the official block ranges (the same 346-block table from `Blocks.txt`, Unicode 17.0.0) sorted by start. Binary-search each codepoint to its block and increment a per-block counter. Compute `hit / size` and a percentage per block.
- Step 4Emit per-font JSON — Write `dist/audit/<font>.coverage.json` with `{ filename, totalGlyphs, totalCodepoints, blockCount, blocks: { 'Basic Latin': { hit, size, pct }, ... } }`. Only include blocks with a non-zero hit, matching the tool's behaviour.
- Step 5Diff against the committed baseline — Compare each block's `hit` against the baseline JSON. A drop in a block listed in your `criticalBlocks` set is a failure; a drop elsewhere is a warning. New coverage is informational.
- Step 6Fail or warn in CI — Exit non-zero on any critical regression so the pipeline blocks the merge. Print a readable diff (`Latin Extended-A: 128 → 96 (-32) BLOCKING`). Update the baseline only with an explicit, reviewed commit.
Browser tool vs Node reimplementation
Why CI uses a Node script rather than driving the hosted tool: the hosted tool returns HTML, CI needs JSON. The logic is identical; only the output format differs.
| Aspect | Hosted Coverage Map | Node CI reimplementation |
|---|---|---|
| Output | HTML matrix (.coverage.html) | JSON per font |
| Codepoints counted | glyph.unicode + glyph.unicodes | Same |
| Block table | 346 blocks (Blocks.txt 17.0.0) | Same table, vendored in the script |
| Empty blocks | Omitted | Omit to match (or keep at 0% if you prefer) |
| Runtime | Browser, opentype.js + wawoff2/pako | Node, opentype.js + decompress helpers |
| Best for | Interactive one-off audits | Baseline diffing, CI gates, batch |
Coverage-regression policy
How to classify a baseline diff. Block vs warn depends on whether your product ships that script.
| Diff | Example | CI action |
|---|---|---|
| Critical block drops | Latin Extended-A 128 → 96 | Fail (exit non-zero) — blocks merge |
| Critical block unchanged | Cyrillic 256 → 256 | Pass |
| Irrelevant block drops | Devanagari 0 → 0 (Latin-only product) | Ignore / informational |
| New coverage added | Greek 0 → 144 | Warn — review and update baseline |
| Total glyphs change, codepoints stable | 5,012 → 4,980, codepoints same | Pass — alternates changed, coverage intact |
| File format changed | weight.ttf → weight.woff2 | Decompress then compare; coverage should match |
Cookbook
A self-contained Node audit script and the CI wiring around it. Adapt the block table and critical-set to your stack; the coverage maths mirrors the hosted tool exactly.
Minimal coverage extractor (Node + opentype.js)
ExampleThe core: parse, collect codepoints, bucket into blocks. Assumes a vendored BLOCKS array of { name, start, end } sorted by start.
import opentype from 'opentype.js';
import { BLOCKS } from './unicode-blocks.js';
function blockFor(cp) {
let lo = 0, hi = BLOCKS.length - 1;
while (lo <= hi) {
const m = (lo + hi) >> 1, b = BLOCKS[m];
if (cp < b.start) hi = m - 1;
else if (cp > b.end) lo = m + 1;
else return b;
}
return null;
}
export function coverage(path) {
const font = opentype.loadSync(path); // .ttf/.otf
const cps = new Set();
for (let i = 0; i < font.glyphs.length; i++) {
const g = font.glyphs.get(i);
if (g.unicode != null) cps.add(g.unicode);
if (g.unicodes) for (const u of g.unicodes) cps.add(u);
}
const counts = {};
for (const cp of cps) {
const b = blockFor(cp);
if (b) counts[b.name] = (counts[b.name] || 0) + 1;
}
return { totalGlyphs: font.glyphs.length, totalCodepoints: cps.size, blocks: counts };
}Emit a per-font JSON baseline
ExampleWalk the fonts dir and write one JSON per file. Commit dist/audit as the baseline at adoption time.
import fs from 'node:fs';
import { coverage } from './coverage.js';
fs.mkdirSync('dist/audit', { recursive: true });
for (const f of fs.readdirSync('src/fonts').filter(n => /\.(ttf|otf)$/.test(n))) {
const cov = coverage(`src/fonts/${f}`);
fs.writeFileSync(`dist/audit/${f}.coverage.json`, JSON.stringify(cov, null, 2));
console.log(`${f}: ${Object.keys(cov.blocks).length} blocks, ${cov.totalCodepoints} cps`);
}Diff against baseline and fail on critical regressions
ExampleCompare current coverage to the committed baseline; exit non-zero if a block in CRITICAL drops.
const CRITICAL = new Set(['Basic Latin','Latin-1 Supplement','Latin Extended-A','Cyrillic']);
let failed = false;
for (const f of fonts) {
const base = readJSON(`baseline/${f}.coverage.json`).blocks;
const now = coverage(`src/fonts/${f}`).blocks;
for (const name of Object.keys(base)) {
const before = base[name], after = now[name] || 0;
if (after < before) {
const tag = CRITICAL.has(name) ? 'BLOCKING' : 'warn';
console.log(`${f} ${name}: ${before} -> ${after} (${tag})`);
if (CRITICAL.has(name)) failed = true;
}
}
}
process.exit(failed ? 1 : 0);Handle WOFF/WOFF2 inputs
Exampleopentype.js reads sfnt (TTF/OTF). Decompress WOFF2/WOFF to sfnt first so web-delivery fonts can be audited too.
import { decompress } from 'wawoff2'; // WOFF2 -> sfnt
import zlib from 'node:zlib'; // WOFF tables are zlib
async function loadAny(path, bytes) {
if (path.endsWith('.woff2')) return opentype.parse(toAB(await decompress(bytes)));
if (path.endsWith('.woff')) return opentype.parse(woffToSfnt(bytes, zlib));
return opentype.parse(toAB(bytes)); // ttf/otf
}
// Audit the same bytes you actually ship (the .woff2) so coverage
// reflects the delivered file, not just the source TTF.GitHub Actions step
ExampleRun the audit on every PR; the non-zero exit blocks the merge when a critical block regresses.
- name: Font coverage audit
run: |
node scripts/coverage-baseline.mjs # writes dist/audit/*.json
node scripts/coverage-diff.mjs # exits 1 on critical regression
# Baseline lives in baseline/*.coverage.json, committed and reviewed.
# Intentional coverage changes require an explicit baseline update PR.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 update silently drops a script from one weight
Regression caughtThe whole point of the gate. A new Bold that lost Latin Extended-A coverage diffs against the baseline as Latin Extended-A: 128 -> 96 (BLOCKING) and exits non-zero. Without the gate this ships and surfaces as tofu in production weeks later.
Total glyphs changed but coverage is identical
PassA foundry can add or remove unencoded alternates without changing the encoded character set. totalGlyphs moves; totalCodepoints and the per-block counts don't. Diff on block counts, not glyph totals, so cosmetic glyph-set churn doesn't trip the gate.
Auditing the TTF source but shipping the WOFF2
VerifyCoverage should be identical between a TTF and its WOFF2 (compression doesn't change the cmap), but a broken build step that subsets during WOFF2 conversion can diverge them. Audit the file you actually ship — decompress the WOFF2 and read its cmap — so the report reflects delivered bytes.
Block table out of date vs the font's Unicode version
MaintenanceIf a new font uses a block added in a Unicode version newer than your vendored table, those codepoints fall outside every known block and blockFor returns null — they're dropped from the per-block tally though they still count in totalCodepoints. Keep the block table current (Unicode 17.0.0 has 346 blocks) and refresh it when Unicode publishes a new version.
Empty-block handling differs from the baseline
ConsistencyThe hosted tool omits zero-coverage blocks. If your script keeps them at 0% and your baseline doesn't (or vice-versa), every diff is noisy. Pick one convention — omit blocks with zero hits — and apply it to both the baseline and the current run.
Variable font weights share one cmap
ExpectedIf your design system ships a single variable font, it has one glyph set and one cmap — audit it once, not per named instance. Static-instance exports each need their own audit because subsetting/instancing can change the encoded set.
opentype.js can't parse an exotic table
Build erroropentype.js may throw on malformed or unusual tables. Wrap each font's parse in try/catch, log the filename, and decide policy: a parse failure on a shipping font should fail CI (you can't verify its coverage), not be silently skipped.
Codepoint in an unassigned gap
ExpectedA glyph mapped to a codepoint outside every named block (an unassigned gap) is skipped by blockFor — counted in totalCodepoints but in no per-block bucket. Rare in well-built fonts; if it spikes, the cmap is likely malformed and worth flagging.
Running the hosted tool via the runner returns HTML, not JSON
By designDriving character-coverage-map through the @jadapps/runner returns the same HTML report the browser produces. That's fine for archiving, but a CI diff needs structured data — which is why the Node reimplementation (identical coverage maths, JSON output) is the right tool for the gate.
Frequently asked questions
Why reimplement in Node instead of calling the hosted tool?
The hosted Coverage Map (and the runner) emit an HTML report, not JSON. CI needs structured, diffable data. The coverage logic is tiny — parse with opentype.js, collect glyph.unicode/glyph.unicodes into a Set, binary-search each codepoint into the block table — so reimplementing it in Node for JSON output is straightforward and matches the hosted tool exactly.
What goes in the per-font JSON?
At minimum { filename, totalGlyphs, totalCodepoints, blocks: { 'Basic Latin': hit, ... } }. Include only blocks with a non-zero hit to match the hosted tool. Optionally add pct per block (hit / size) for human readability. Commit this as the baseline at font-adoption time.
What baseline should I commit?
Snapshot every font's coverage at the moment you adopt it. Future builds diff against this. Intentional coverage changes (a deliberate subset, a planned new script) require an explicit, reviewed baseline-update PR so the change is visible in history.
How do I avoid false alarms from glyph-set churn?
Diff on per-block codepoint counts, not on totalGlyphs. Foundries routinely add or remove unencoded alternates; that moves the glyph total but not coverage. Gate on blocks, not on the raw glyph count, and you only fire on real coverage changes.
Which blocks should be critical (blocking) vs warning?
Put the scripts your product actually ships in a criticalBlocks set — typically Basic Latin, Latin-1, Latin Extended-A, and whatever your locales need (Cyrillic, Greek, etc.). Regressions there fail the build. Drops in scripts you don't ship (e.g. Devanagari on a Latin-only product) are warnings or ignored.
Is it fast enough for CI?
Yes — sub-second per font, even for large CJK files. The dominant cost is reading bytes from disk; the Set construction and binary-search bucketing are trivial. Auditing a dozen fonts adds a second or two to the pipeline.
Can I audit WOFF and WOFF2, not just TTF?
Yes. opentype.js reads sfnt (TTF/OTF) directly; decompress WOFF2 to sfnt with wawoff2 and WOFF with zlib/pako first — exactly what the browser tool does. Auditing the WOFF2 you actually ship is best practice, so the report reflects delivered bytes.
Do I need to keep the Unicode block table updated?
Yes. The hosted tool uses the Unicode 17.0.0 table (346 blocks). Vendor the same Blocks.txt-derived array in your script and refresh it when Unicode publishes a new version, so codepoints in newly-added blocks bucket correctly instead of being dropped.
How is this different from a font linter like fontbakery?
fontbakery runs broad correctness checks; this is a focused coverage-regression gate. They're complementary — run fontbakery for table validity and metrics sanity, run the coverage diff to ensure no shipped font silently loses a language between releases.
Can I keep the HTML report as an artefact too?
Yes — drive character-coverage-map through the @jadapps/runner (POST 127.0.0.1:9789/v1/tools/character-coverage-map/run) to get the styled .coverage.html for human review, and use the Node JSON for the machine diff. Many teams archive both per release.
Does the audit upload my fonts anywhere?
The Node reimplementation runs entirely on your CI runner — fonts never leave it. The optional runner path also runs locally on your machine via @jadapps/runner; only an anonymous run counter is recorded, never font bytes. Safe for unreleased brand fonts.
What about subsetting in the pipeline?
Audit before and after subsetting. The coverage report confirms the subset kept the scripts you need and dropped the rest. Build the subsets with font-subsetter; for the broader build-pipeline pattern see automate font subsetting.
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.