How to generate typography design tokens from font metrics
- Step 1Walk the fonts directory — `fs.readdirSync(fontsDir)` and filter to `.ttf`/`.otf`/`.woff`/`.woff2`. (For WOFF/WOFF2 in Node you'll decompress to sfnt first — `wawoff2` for WOFF2, `pako` for WOFF — exactly as the web tool does, or run them through the runner which handles it.)
- Step 2Parse each font and read the metric tables — `opentype.parse(buf)`, then read `font.unitsPerEm`, `font.tables.os2.sTypoAscender/sTypoDescender/sTypoLineGap`, `usWinAscent/usWinDescent`, `sxHeight`, `sCapHeight`, and `font.tables.hhea.*`. Guard for missing OS/2 (fall back to hhea).
- Step 3Compute the line-height with the analyser's formula — `typoLH = (typoAsc + Math.abs(typoDesc) + typoGap) / upm`; `winLH = (winAsc + winDesc) / upm`; `safeLH = +Math.max(typoLH, winLH, 1.0).toFixed(3)`. This is byte-for-byte what the web tool reports as `recommended_line_height.safe`.
- Step 4Compute the ratios — `xHeightRatio = sxHeight / upm`; `capHeightRatio = sCapHeight / upm`. Use ratios, not raw units, because they're invariant across font-size and across fonts with different UPM.
- Step 5Emit W3C design tokens — Wrap each value as `{ "$value": <n>, "$type": "number" }` under a per-font group keyed by family name. Write one tokens JSON per build.
- Step 6Feed Style Dictionary — Point Style Dictionary at the tokens file; it produces CSS custom properties (`--font-body-line-height: 1.5;`), JS objects, or platform outputs. Wire the script into CI so tokens regenerate whenever the fonts change.
Token → metric source → formula
The line-height matches the web tool exactly. The ratios are extra (the web tool reports raw x_height/cap_height; you divide by UPM to get the token).
| Token | Source fields | Formula |
|---|---|---|
line-height | OS/2 typo + win, head UPM | max((typoAsc+|typoDesc|+typoGap)/UPM, (winAsc+winDesc)/UPM, 1.0) |
x-height-ratio | OS/2.sxHeight, head.unitsPerEm | sxHeight / unitsPerEm |
cap-height-ratio | OS/2.sCapHeight, head.unitsPerEm | sCapHeight / unitsPerEm |
line-height-typo (optional) | OS/2 typo, head UPM | (typoAsc + |typoDesc| + typoGap) / UPM |
line-height-win (optional) | OS/2 win, head UPM | (winAsc + winDesc) / UPM |
Two automation paths compared
Both produce the same numbers. The runner path reuses JAD's exact handler; the Node path is a few lines of opentype.js you own.
| Aspect | opentype.js in Node | Local runner API |
|---|---|---|
| Tier | Any (it's your own code) | Paid (runner must be paired) |
| Endpoint | opentype.parse() in-process | POST http://127.0.0.1:9789/v1/tools/font-metrics-analyzer/run |
| Input | Buffer you read | multipart files=@font.ttf |
| WOFF/WOFF2 handling | You add wawoff2 / pako | Handled by the runner |
| Output | Whatever you build | The analyser's JSON (recommended_line_height, etc.) |
| Options | n/a | None — GET /api/v1/tools/font-metrics-analyzer returns options: [] |
Null-handling matrix
Real fonts omit fields. Decide per token how CI behaves so a missing value doesn't emit a misleading token.
| Missing field | Affected token | Recommended CI behaviour |
|---|---|---|
| No OS/2 table | line-height, both ratios | Fall back to hhea line-height; skip ratio tokens |
sxHeight = 0 / absent | x-height-ratio | Omit token; warn in build log |
sCapHeight = 0 / absent | cap-height-ratio | Omit token; warn in build log |
| typo present, win absent | line-height | safe falls back to typo (or 1.0 floor) |
Cookbook
Copy-pasteable automation. The Node script mirrors the web tool's exact math; the runner snippet calls JAD's own handler. For what each metric means see the tables reference; for the per-font line-height usage see the vertical-rhythm guide.
Node script: metrics → W3C tokens
ExampleWalks a fonts directory, computes the analyser's exact line-height plus ratios, and writes a tokens file. TTF/OTF only here; add wawoff2/pako for web fonts.
import fs from "node:fs";
import path from "node:path";
import opentype from "opentype.js";
const dir = "src/fonts";
const tokens = {};
for (const file of fs.readdirSync(dir).filter(f => /\.(ttf|otf)$/i.test(f))) {
const font = opentype.parse(fs.readFileSync(path.join(dir, file)).buffer);
const upm = font.unitsPerEm;
const os2 = font.tables.os2 ?? {};
const typoLH = (os2.sTypoAscender + Math.abs(os2.sTypoDescender) + (os2.sTypoLineGap ?? 0)) / upm;
const winLH = (os2.usWinAscent + os2.usWinDescent) / upm;
const safe = +Math.max(typoLH, winLH, 1.0).toFixed(3);
const family = path.parse(file).name;
tokens[family] = {
"line-height": { "$value": safe, "$type": "number" },
"x-height-ratio": { "$value": +(os2.sxHeight / upm).toFixed(4), "$type": "number" },
"cap-height-ratio": { "$value": +(os2.sCapHeight / upm).toFixed(4), "$type": "number" },
};
}
fs.writeFileSync("tokens/typography.json", JSON.stringify(tokens, null, 2));Emitted token JSON
ExampleWhat the script writes. Style Dictionary consumes this shape directly.
{
"Inter-Regular": {
"line-height": { "$value": 1.21, "$type": "number" },
"x-height-ratio": { "$value": 0.5459, "$type": "number" },
"cap-height-ratio": { "$value": 0.7275, "$type": "number" }
},
"SourceSerif-Regular": {
"line-height": { "$value": 1.32, "$type": "number" },
"x-height-ratio": { "$value": 0.475, "$type": "number" },
"cap-height-ratio": { "$value": 0.66, "$type": "number" }
}
}Runner path on a paid tier
ExampleReuse JAD's exact handler instead of writing the math. POST the font, get the analyser JSON, pluck the safe line-height.
# Schema first (confirms it takes no options):
curl -sS http://127.0.0.1:9789/../api/v1/tools/font-metrics-analyzer
# → { ..., "options": [] }
# Run it:
curl -sS -X POST http://127.0.0.1:9789/v1/tools/font-metrics-analyzer/run \
-F 'files=@src/fonts/Inter-Regular.ttf' \
| jq '.recommended_line_height.safe'
# → 1.21CI step that regenerates tokens on font change
ExampleWire the Node script into the build so a font update reshapes the tokens automatically. No manual line-height edits.
- name: Generate typography tokens
run: |
npm i opentype.js style-dictionary
node scripts/font-metrics-tokens.mjs
npx style-dictionary build --config sd.config.json
- name: Fail if tokens changed but weren't committed
run: git diff --exit-code tokens/ build/css/typography.cssStyle Dictionary → CSS custom properties
ExampleStyle Dictionary turns the number tokens into CSS variables your stylesheet references.
/* generated by Style Dictionary from typography.json */
:root {
--inter-regular-line-height: 1.21;
--inter-regular-x-height-ratio: 0.5459;
--inter-regular-cap-height-ratio: 0.7275;
}
body { font-family: "Inter"; line-height: var(--inter-regular-line-height); }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.
Font in the directory has no OS/2 table
Handled — fall back to hheafont.tables.os2 is undefined, so sTypoAscender etc. are undefined and arithmetic yields NaN. Guard with os2 = font.tables.os2 ?? {} and detect: if the typo fields are missing, compute line-height from font.tables.hhea instead ((hhea.ascender + |hhea.descender| + hhea.lineGap)/upm) and skip the ratio tokens. The web tool does the same fallback internally.
sxHeight or sCapHeight is 0
Skip the ratio tokenDividing zero by UPM gives a 0 ratio, which would emit a misleading token (it's not that the x-height is zero — it's unknown). Detect !sxHeight and omit the x-height-ratio token, logging a warning. Don't emit 0 — downstream size-matching would scale to nothing.
Reading a WOFF2 directly in Node
Needs decompressionopentype.parse expects an sfnt; a raw WOFF2 buffer will fail. Decompress with wawoff2.decompress() (WOFF2) or pako.inflate per-table (WOFF) before parsing — the exact steps the web tool uses. Simpler on paid tiers: POST the WOFF2 to the runner, which decompresses it for you and returns the metrics JSON.
Variable font in the directory
Default-instance tokensopentype.js reads the static OS/2/head values, i.e. the default instance's metrics — not per-axis MVAR values. If your design system ships multiple instances with different metrics, freeze each one (fonttools varLib.instancer or variable-font-freezer) and run the script over the frozen files so each instance gets correct tokens.
Token diff churns on every build
Round before emittingFloating-point ratios with full precision change in the last digits across machines, churning your git diff. Round deliberately (.toFixed(3) for line-height, .toFixed(4) for ratios, matching the web tool's 3-dp line-height) so the committed tokens only change when the metric meaningfully changes.
Runner not paired on a paid tier
Error — falls back to in-browserThe runner API at 127.0.0.1:9789 only answers when the runner is running and paired. In a headless CI box, pair it as part of the job or just use the pure opentype.js path, which has no runner dependency. Check GET /v1/health on the runner before POSTing.
Family name collisions across files
Key by filename, not just familyTwo files (e.g. Regular and a webfont subset) can share an internal family name, overwriting each other in the tokens object. Key the token group by the filename stem (or filename + style) rather than the OS/2 family name to keep every face distinct.
post-table tokens (underline, italic angle) requested
Not from this metric pathThe analyser handler — and therefore the obvious automation around it — doesn't read the post table. If your tokens need underlinePosition/underlineThickness/italicAngle, read font.tables.post.* yourself in the same script, or pull them from font-metadata-extractor. Don't expect them in the metrics JSON.
TTC (collection) file in the directory
Rejected by the runner / needs special parseThe runner's metrics handler only accepts ttf/otf/woff/woff2 and rejects .ttc. In your own Node script, opentype.js can parse a specific font from a collection if you pass the right offset, but it's easier to extract the faces first. Filter .ttc out of the directory walk or pre-split it.
Frequently asked questions
Why emit ratios instead of absolute pixel values?
Ratios are font-size-invariant. An x-height of 515 in a 1000-UPM font is ratio 0.515; at 16px that's 8.24px, at 24px it's 12.36px. The ratio is the universal token; the px value is derived at render time. Storing ratios also lets you compare across fonts with different UPM, which raw units can't do.
Does the line-height token match the web tool exactly?
Yes — use the same formula: max((sTypoAscender + |sTypoDescender| + sTypoLineGap)/unitsPerEm, (usWinAscent + usWinDescent)/unitsPerEm, 1.0) rounded to three decimals. That's literally what the analyser computes for recommended_line_height.safe. If you call the runner instead of recomputing, you get that value directly in the JSON.
Should I store typo or win metrics in the tokens?
Store enough to decide later: emit the safe line-height as the primary token, and optionally line-height-typo and line-height-win as separate tokens. Then your consumer (or USE_TYPO_METRICS detection) chooses. Most modern fonts set the typo flag, so the typo value is usually the right default, but keeping both costs nothing.
Can the web tool itself emit tokens or process a folder?
No. The web UI is single-file and outputs raw metrics JSON, not design tokens. The token shaping (ratios, $value/$type, family grouping) is your script's job. The tool gives you the measured inputs; this guide gives you the wrapper to turn a folder of them into a tokens file.
What does GET /api/v1/tools/font-metrics-analyzer return?
The tool's schema with an empty options array ("options": []) — confirming there are no parameters to pass. You POST a single font as a multipart files field to /v1/tools/font-metrics-analyzer/run and get back the metrics JSON. No quality, format, or charset knobs exist for this tool.
How do I handle WOFF/WOFF2 in the Node script?
opentype.js needs sfnt, so decompress first: wawoff2.decompress() for WOFF2 (Brotli) and per-table pako.inflate for WOFF (zlib), then parse the result — the same pipeline the web tool runs. Or skip the plumbing and POST the web font to the runner on a paid tier, which decompresses it for you.
What if a font has no x-height or cap-height?
Detect !sxHeight / !sCapHeight and omit those ratio tokens with a build warning rather than emitting 0. A zero ratio is not 'tiny x-height' — it's 'unknown', and downstream size-matching would scale glyphs to nothing. The line-height token is unaffected since it doesn't use those fields.
Will the runner send my fonts to JAD's servers?
No. The runner executes the tool locally on your machine — font bytes never leave it. That's the privacy advantage of the runner path for proprietary or pre-release fonts in CI. The web UI is equally local (everything runs in-browser); only the cloud API would involve a server, and metrics analysis doesn't use one.
How do I stop the token file churning in git?
Round the emitted numbers (.toFixed(3) for line-height, .toFixed(4) for ratios). Full-precision floats vary in their trailing digits across machines and Node versions, producing noisy diffs. Deterministic rounding means the tokens only change when a font's metrics actually change — which is exactly when you want a diff.
Can I extend this to fallback override descriptors?
Yes — compare each web font's metrics against a system fallback's and emit ascent-override/descent-override/size-adjust values per pairing. The arithmetic is in the fallback-matching guide. You'd add the fallback fonts' metrics to the same script and write the descriptor strings as tokens or directly into @font-face blocks.
How do I keep CI from passing when tokens are stale?
Regenerate tokens in the build, then git diff --exit-code on the tokens directory and the Style Dictionary output. If a font changed and someone forgot to commit the regenerated tokens, the diff is non-empty and CI fails — forcing the tokens to track the binaries.
Does subsetting the fonts before this step change the tokens?
No. Subsetting removes glyphs but not the head/OS/2/hhea metric fields, so line-height and the ratios are identical for the full and subset fonts. You can run the token script on either — typically on the shipped (subset) WOFF2 so the tokens describe exactly what users download. Subset with font-subsetter earlier in the pipeline.
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.