How to automate font metadata extraction in ci
- Step 1Decide: reimplement or run via the runner — Two valid paths. (1) Reimplement the extraction in Node with opentype.js — full control, no JAD dependency at runtime. (2) Pair the [@jadapps/runner](/docs/runner) and POST to `http://127.0.0.1:9789/v1/tools/font-metadata-extractor/run` — the runner executes the same browser engine locally, and no font ever uploads to JAD's servers (the public `/api/v1/.../run` endpoint deliberately refuses uploads).
- Step 2Walk the fonts directory — `fs.readdir` your fonts folder, filter to `.ttf/.otf/.woff/.woff2`. For each, read the bytes and detect the format from the first four magic bytes (`0x00010000` TTF, `OTTO` OTF, `wOFF`, `wOF2`) — exactly as the browser tool does — so a mislabelled extension doesn't trip the parse.
- Step 3Decompress, then parse — For WOFF2, Brotli-decompress to an sfnt buffer (wawoff2); for WOFF, zlib-inflate per table and rebuild the sfnt; TTF/OTF pass through. Then `opentype.parse(buffer)` and read `font.names` via `getEnglishName`, plus `font.unitsPerEm`, `font.numGlyphs`, `font.outlinesFormat`, and `Object.keys(font.tables)`.
- Step 4Emit one JSON per font — Write `{ filename, file_size, units_per_em, num_glyphs, outlines_format, tables_present, names }` to `dist/audit/<font>.json` — the same shape the browser tool downloads. Add `sha256` and a timestamp if you want a full manifest entry.
- Step 5Diff against the committed baseline — Before deploy, compare `dist/audit/` against the committed `audit/`. A difference means a font changed — fail the build and require an explicit acknowledgement (a PR edit to the baseline) so version bumps are deliberate, not accidental.
- Step 6Commit the manifest — Check the audit JSON into source control. It's both the diff baseline and a long-lived record of which fonts shipped, at what version, under what licence — invaluable for the compliance and version-drift reviews described in the [licensing](/font-tools/guides/font-metadata-licensing-compliance) and [versioning](/font-tools/guides/font-metadata-design-system-versioning) guides.
Two automation paths compared
Both keep font binaries on your own infrastructure. The runner path uses JAD's exact engine; the reimplementation path removes the JAD runtime dependency.
| Aspect | Reimplement in Node (opentype.js) | Drive the tool via @jadapps/runner |
|---|---|---|
| Dependency | opentype.js + a WOFF2 decoder | The paired runner on localhost |
| Where it runs | Your CI runner / dev machine | Your machine (runner-hosted browser engine) |
| Uploads to JAD? | No — never touches JAD | No — runner executes locally; public /run refuses uploads |
| Output shape | You build it (match the tool's shape) | Identical to the browser tool's JSON |
| Best for | Full control, no runtime JAD dependency | Parity with the hosted tool, minimal code |
opentype.js fields → output JSON
The exact mapping the browser tool uses, so a Node reimplementation produces a byte-comparable shape.
| Output key | opentype.js source | Notes |
|---|---|---|
units_per_em | font.unitsPerEm | Design grid |
num_glyphs | font.numGlyphs | Glyph count |
outlines_format | font.outlinesFormat | truetype or cff |
tables_present | Object.keys(font.tables).sort() | Sorted table tags |
names | font.getEnglishName(key) per key | English records only; skip empties |
filename / file_size | From the File object | Original (compressed) size |
API endpoints for the runner path
Font tools are browser-only; the public API never accepts uploads. The schema is discoverable, but execution happens on your paired local runner.
| Endpoint | Purpose | Behaviour |
|---|---|---|
GET /api/v1/tools/font-metadata-extractor | Discover tool info | Returns tool metadata (category, accepted file types) |
POST /api/v1/tools/font-metadata-extractor/run | (Hosted run — not supported) | Returns 400 with pairing instructions; never accepts a font upload |
POST http://127.0.0.1:9789/v1/tools/font-metadata-extractor/run | Run on your machine | The paired runner executes the same engine locally; no file leaves your network |
Cookbook
Copy-pasteable Node and CI snippets. The reimplementation matches the browser tool's output shape exactly; the runner path drives the real tool locally. For what each field means see the metadata how-to.
Minimal Node reimplementation (TTF/OTF)
ExampleFor uncompressed fonts you don't even need a decompressor — opentype.js reads the sfnt directly. This emits the same JSON the browser tool downloads.
// extract-metadata.mjs
import fs from 'node:fs';
import opentype from 'opentype.js';
const NAME_LABELS = {
copyright:'Copyright', fontFamily:'Family', fontSubfamily:'Subfamily',
uniqueID:'Unique ID', fullName:'Full name', version:'Version',
postScriptName:'PostScript name', trademark:'Trademark',
manufacturer:'Manufacturer', designer:'Designer', description:'Description',
manufacturerURL:'Manufacturer URL', designerURL:'Designer URL',
license:'License', licenseURL:'License URL', sampleText:'Sample text',
};
function extract(path) {
const buf = fs.readFileSync(path);
const font = opentype.parse(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
const names = {};
for (const k of Object.keys(font.names ?? {})) {
const v = font.getEnglishName(k);
if (v) names[NAME_LABELS[k] ?? k] = v;
}
return {
filename: path.split('/').pop(),
file_size: buf.byteLength,
units_per_em: font.unitsPerEm,
num_glyphs: font.numGlyphs,
outlines_format: font.outlinesFormat,
tables_present: Object.keys(font.tables).sort(),
names,
};
}
console.log(JSON.stringify(extract(process.argv[2]), null, 2));Handling WOFF2 in Node
ExampleWOFF2 must be Brotli-decompressed to an sfnt before opentype.js can parse it — the same step the browser tool runs via wawoff2. Decompress first, then feed the sfnt buffer to opentype.parse.
import wawoff2 from 'wawoff2'; // wasm WOFF2 codec
import opentype from 'opentype.js';
import fs from 'node:fs';
const woff2 = fs.readFileSync('Inter-Regular.woff2');
const sfnt = await wawoff2.decompress(new Uint8Array(woff2));
const font = opentype.parse(sfnt.buffer.slice(sfnt.byteOffset, sfnt.byteOffset + sfnt.byteLength));
// font.names, font.numGlyphs, font.outlinesFormat ... as before
// WOFF 1.0: inflate each table's zlib stream and rebuild the sfnt,
// then parse the same way.Walk a directory, write one JSON per font
ExampleThe CI core: enumerate fonts, extract each, write a manifest file alongside. Commit dist/audit/ as the baseline future builds diff against.
import fs from 'node:fs';
import path from 'node:path';
const SRC = 'public/fonts', OUT = 'dist/audit';
fs.mkdirSync(OUT, { recursive: true });
for (const f of fs.readdirSync(SRC)) {
if (!/\.(ttf|otf|woff|woff2)$/i.test(f)) continue;
const meta = await extractAny(path.join(SRC, f)); // TTF/OTF/WOFF/WOFF2 aware
fs.writeFileSync(path.join(OUT, f + '.json'), JSON.stringify(meta, null, 2));
console.log(`extracted ${f}: ${meta.names.Family} ${meta.names.Version ?? ''}`);
}CI gate: fail on unexpected drift
ExampleDiff the freshly-extracted manifest against the committed baseline. Any difference fails the build until someone acknowledges it by updating the baseline in the PR.
# package.json
"scripts": {
"fonts:extract": "node extract-all.mjs",
"fonts:check": "node extract-all.mjs && git diff --exit-code dist/audit/"
}
# CI step
- run: npm run fonts:check
# exits non-zero if any font's metadata changed since the
# committed baseline -> reviewer must intentionally update itRunner path: drive the real tool locally
ExampleIf you'd rather use JAD's exact engine than reimplement, pair the runner and POST to localhost. The hosted /api/v1 endpoint only returns the schema and pairing instructions — it never accepts the font itself.
# 1. Pair the runner once (see /docs/runner) # 2. Discover the tool (schema/info), no upload: curl -H "Authorization: Bearer $JAD_KEY" \ https://<host>/api/v1/tools/font-metadata-extractor # 3. Execute on YOUR machine via the local runner: curl -X POST http://127.0.0.1:9789/v1/tools/font-metadata-extractor/run \ -F file=@public/fonts/Inter-Regular.woff2 # -> same metadata JSON the browser tool produces; the font # never leaves your network.
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.
Posting a font to the hosted /run endpoint
400 Runner requiredFont tools are browser-only, and JAD's public POST /api/v1/tools/.../run deliberately refuses uploads — it returns 400 with pairing instructions and the localhost runner endpoint. Don't build a pipeline that uploads fonts to the hosted API; either reimplement in Node or POST to the paired runner on 127.0.0.1:9789.
WOFF2 fed to opentype.parse without decompressing
Parse erroropentype.js cannot parse a WOFF2 (or WOFF) directly — it expects an sfnt. You must Brotli-decompress WOFF2 (wawoff2) or zlib-inflate WOFF first. The browser tool does this transparently; a Node reimplementation must do it explicitly, or every WOFF2 will throw.
wawoff2 WASM init race in a CI runner
Timeout riskThe published wawoff2 wrapper has a known init race where the runtime callback can be missed and decompress hangs. The browser tool works around it by racing onRuntimeInitialized, Module.calledRun, and the bound function. In Node, either use a wrapper version that handles this or add your own readiness poll with a timeout so CI fails fast instead of hanging.
Empty names object on a non-English-only font
English onlygetEnglishName returns only the English record. A font that stores its records solely in another language yields a thin or empty names object even though records exist. If your audit must capture every platform/language record, the opentype.js path won't suffice — drop to fonttools ttx for those specific fonts.
TrueType Collection (.ttc) in the fonts folder
UnsupportedThe browser tool rejects .ttc, and the simple opentype.parse path won't handle a collection either. Filter .ttc out of your walk, or split each face first. If you skip them silently, your manifest will be missing those faces with no error — log skips explicitly.
File over the tier limit (runner path)
413-style rejectIf you drive the tool via the runner, the same per-file size limits apply (5 MB free, 50 MB Pro, 1 GB Developer). A reimplementation in pure Node has no such limit — only available memory — so for very large fonts the Node path is the more flexible automation route.
Subset webfonts make num_glyphs/SHA noisy
ReviewIf your build subsets fonts per page, the manifest's num_glyphs and any fingerprint change every build, drowning real drift in noise. Pin the pre-subset source font's metadata for drift detection, and track the subset config (input glyph set) rather than the output binary.
Version string has no stable format to diff on
Opaque stringDiffing on nameID 5 alone is fragile because foundries format it inconsistently and some don't bump it. Diff the whole manifest entry (version + num_glyphs + tables_present + sha256) so a change in any dimension trips the gate, not just the version string.
opentype.js can't write the name table back
Read-only by designIf your pipeline tries to edit records after extraction, note that opentype.js's name-table writer is unreliable for some platform/encoding combinations — the browser tool is read-only for exactly this reason. For edits, use a desktop tool or fonttools and re-extract to verify, rather than round-tripping through opentype.js.
Mislabelled extension (WOFF2 named .ttf)
HandledDetect format from the first four magic bytes, not the extension — the browser tool does. A WOFF2 named .ttf will fail a naive 'if .ttf parse directly' branch. Sniff the magic (wOF2 = 0x774F4632, wOFF = 0x774F4646, 0x00010000 TTF, OTTO OTF) and route to the right decoder.
Frequently asked questions
Is there a server API I can POST fonts to?
No — font tools are browser-only and JAD's public /api/v1/tools/.../run refuses uploads, returning 400 with pairing instructions. For automation you either reimplement the extraction in Node with opentype.js, or pair the @jadapps/runner and POST to http://127.0.0.1:9789/v1/tools/font-metadata-extractor/run, which runs the same engine locally so the font never leaves your network.
What library does the extraction use?
opentype.js, plus a WASM WOFF2 (Brotli) decoder for WOFF2 and zlib for WOFF. The browser tool reads font.names via getEnglishName, font.unitsPerEm, font.numGlyphs, font.outlinesFormat, and Object.keys(font.tables). A Node script using the same calls produces the same JSON shape — no Python, no fonttools required.
Do I need fonttools or Python?
No. The whole extraction is pure JavaScript via opentype.js. That's a deliberate advantage for JS/TS CI pipelines — no separate Python toolchain to install on runners. You only need fonttools if you require records the opentype.js path doesn't surface (e.g. non-English name records or per-platform dumps).
How do I handle WOFF2 in my script?
Brotli-decompress it to an sfnt buffer first (the wawoff2 WASM codec), then pass that buffer to opentype.parse. WOFF 1.0 needs per-table zlib inflation and an sfnt rebuild. TTF/OTF parse directly. The browser tool does all of this transparently; a reimplementation must branch on the detected format.
Should I commit the audit JSON?
Yes. The committed manifest is both the baseline your CI gate diffs against and a durable record of what fonts shipped — at what version, with what licence — useful for compliance and incident review months or years later. An intentional font bump becomes a reviewable change to that committed file.
Does this catch drift in third-party fonts?
Yes — re-extracting at build time catches changes in Google Fonts, Adobe, Fontshare, or any CDN-served font, and the diff against the baseline flags them for review. Add a SHA-256 (via the Font Fingerprinter) to catch the silent updates that don't bump the version string.
What about variable fonts?
Variable fonts carry the same name records as static ones, so extraction works identically; tables_present will include fvar. The extractor doesn't enumerate the axes themselves — if you need to bake a specific instance in your pipeline use the Variable Font Freezer, and track the frozen output as its own manifest entry.
What output fields will the script produce?
Matching the browser tool: filename, file_size, units_per_em, num_glyphs, outlines_format, tables_present (sorted), and names (labelled English records). Note it does NOT produce an OS/2 or post numeric dump — for vertical metrics add the Font Metrics Analyzer logic separately.
How do I add a fingerprint to each entry?
Hash the raw file bytes with SHA-256 (crypto.subtle.digest in the browser, node:crypto in Node) and add it as a sha256 field. That's exactly what the Font Fingerprinter does — combining it with the metadata gives you both a human-readable version and a tamper-evident pin in one manifest entry.
Will the runner upload my fonts to JAD?
No. The @jadapps/runner executes on your machine and processes files locally; the hosted API explicitly never receives file content. That's the whole point of the runner architecture — you get the hosted tool's exact behaviour without your binaries leaving your network.
How do I make the build fail on unexpected changes?
Re-extract into a temp dir and git diff --exit-code (or a deep JSON compare) against the committed baseline. A non-zero exit fails CI. The reviewer then either fixes an accidental font change or acknowledges an intentional one by committing the updated baseline in the same PR.
Can I extract from a .ttc collection in the script?
Not with the simple opentype.parse path — collections aren't supported (the browser tool rejects them too). Split each face out first (fonttools), then run the per-face TTF/OTF through your extractor. Filter .ttc from your directory walk and log the skip so the manifest gap is visible.
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.