How to analyse font metrics: upm, ascender, x-height, cap-height
- Step 1Drop one font file onto the analyser — Accepts a single `.ttf`, `.otf`, `.woff`, or `.woff2` up to **5 MB** on the free tier (50 MB Pro, 1 GB Developer). There is no batch mode in the UI — it processes one file per run. WOFF2 is Brotli-decompressed and WOFF is zlib-inflated to a flat sfnt before the metric tables are read.
- Step 2Press Process — There are no options to configure — the analyser has an empty option schema, so the panel shows just the upload zone and a Process button. Parsing a typical 30-200 KB font is effectively instant; a 5 MB CJK font is a second or two.
- Step 3Read the four summary pills — Above the JSON the result shows four metrics at a glance: **UPM**, **x-height**, **cap-height**, and **Safe line-height**. If x-height or cap-height shows `?`, the font left `sxHeight` / `sCapHeight` at zero or omitted them (common in older or display fonts) — the JSON will carry `null` for those fields.
- Step 4Inspect the full JSON — The body shows `units_per_em`, a `typo` block (`ascender`, `descender`, `line_gap`), an `hhea` block, a `win` block (`ascent`, `descent`), `x_height`, `cap_height`, and `recommended_line_height` with `typo`, `win`, and `safe` sub-values. Copy with the Copy button or Download the `.metrics.json` file.
- Step 5Apply the recommended line-height — Paste the `css_recommendation` value (e.g. `line-height: 1.485;`) onto the element using the font, or store `recommended_line_height.safe` as a design token. Keep it **unitless** so it inherits as a multiplier across nested font-sizes.
- Step 6Decide typo vs win for your render targets — If your typography is Windows-heavy and you've seen accents clip, prefer the `win` value. If you target modern browsers that honour `USE_TYPO_METRICS`, the `typo` value is the designer's intent. When in doubt the `safe` value (the larger) avoids clipping everywhere at the cost of a slightly looser line.
What the analyser reads and what it ignores
Mapped to the OpenType tables. The analyser reads only the fields it needs for line-height and size-matching — it is not a full table dumper. For other metrics use the sibling tool named in the right column.
| Field in the JSON | Source table.field | Read by this tool? | Where to get it instead |
|---|---|---|---|
units_per_em | head.unitsPerEm | Yes | — |
typo.ascender / .descender / .line_gap | OS/2.sTypoAscender / sTypoDescender / sTypoLineGap | Yes | — |
win.ascent / .descent | OS/2.usWinAscent / usWinDescent | Yes | — |
hhea.ascender / .descender / .line_gap | hhea.ascender / descender / lineGap | Yes | — |
x_height / cap_height | OS/2.sxHeight / sCapHeight | Yes (null if absent) | — |
| underline position / thickness, italic angle | post.underlinePosition / underlineThickness / italicAngle | No — not extracted | font-metadata-extractor |
| glyph count, per-glyph advance widths | maxp.numGlyphs, hmtx | No | glyph-count-analyzer |
| single-glyph bbox + SVG path | glyf / CFF outline | No | glyph-inspector |
The three line-height candidates and how they're computed
All three appear in recommended_line_height. safe is max(typo, win, 1.0), rounded to 3 decimals — the value shown in the summary pill and the css_recommendation string.
| Candidate | Formula | When it's the right one | Null when… |
|---|---|---|---|
typo | (sTypoAscender + |sTypoDescender| + sTypoLineGap) / unitsPerEm | Modern browsers with USE_TYPO_METRICS set — designer's intended leading | OS/2 typo ascender or descender missing |
win | (usWinAscent + usWinDescent) / unitsPerEm | Windows / legacy renderers, or when accents clip — win bounds include diacritic clearance | OS/2 win ascent or descent missing |
safe | max(typo, win, 1.0) → .toFixed(3) | You want one value that never clips on any target | Never — always at least 1.0 |
Input formats and tier limits
Per-file size limits from the font tooling. The analyser is single-file only — there is no folder or multi-file upload in the UI.
| Tier | Max file size | Formats in | Output |
|---|---|---|---|
| Free | 5 MB | TTF, OTF, WOFF, WOFF2 | <name>.metrics.json |
| Pro | 50 MB | TTF, OTF, WOFF, WOFF2 | <name>.metrics.json |
| Developer | 1 GB | TTF, OTF, WOFF, WOFF2 | <name>.metrics.json |
Cookbook
Worked line-height calculations from real metric numbers. The <...> placeholders are the actual fields the analyser reports. For fallback CLS-matching with these numbers see matching fallback fonts by metrics; for building a whole scale on top of the base line-height see the typography scale builder.
1000-UPM font: reading the safe line-height
ExampleA CFF/PostScript-style font at 1000 UPM. typo and win disagree slightly; the analyser reports both and picks the larger as safe.
Input: MyBrand-Regular.otf (units_per_em = 1000)
Reported JSON (abridged):
{
"units_per_em": 1000,
"typo": { "ascender": 800, "descender": -200, "line_gap": 0 },
"win": { "ascent": 950, "descent": 250 },
"x_height": 515,
"cap_height": 700,
"recommended_line_height": {
"typo": 1, // (800 + 200 + 0) / 1000
"win": 1.2, // (950 + 250) / 1000
"safe": 1.2
},
"css_recommendation": "line-height: 1.2;"
}
→ The 'safe' 1.2 protects the tall win bounds from clipping accents.2048-UPM TrueType font: same ratios, different raw units
ExampleTrueType fonts usually use 2048 UPM. The raw numbers are ~2x bigger but the resulting ratio is what matters — dividing by unitsPerEm normalises it.
Input: Liberation-Sans.ttf (units_per_em = 2048) typo.ascender = 1854 typo.descender = -434 typo.line_gap = 67 win.ascent = 1854 win.descent = 434 recommended_line_height.typo = (1854 + 434 + 67) / 2048 = 1.150 recommended_line_height.win = (1854 + 434) / 2048 = 1.117 recommended_line_height.safe = 1.150 css_recommendation: "line-height: 1.15;" → UPM is just the coordinate scale; the line-height ratio is identical whether the font is 1000 or 2048 UPM internally.
Applying the value as a CSS custom property
ExampleRather than hard-coding 1.15, store the analyser's safe value as a token so the rest of the system references one source of truth.
:root {
--font-body: "MyBrand", system-ui, sans-serif;
--leading-body: 1.15; /* = recommended_line_height.safe */
}
body {
font-family: var(--font-body);
line-height: var(--leading-body);
}
/* Keep it unitless so nested smaller text scales its leading too. */x-height matching two fonts before mixing them
Examplex-height drives how 'big' a font looks at a given size. Two fonts at the same font-size but different x-heights look mismatched. Use the reported ratios to size-correct.
Heading font: cap_height 700 / units_per_em 1000 → ratio 0.700 Body font: x_height 515 / units_per_em 1000 → ratio 0.515 Fallback Arial: x_height (run separately) ≈ 1062 / 2048 → 0.519 Body vs Arial x-height ratio: 0.515 / 0.519 = 0.992 → Arial reads ~1% smaller; a size-adjust: 99.2% on the fallback @font-face brings them flush. (You compute the descriptor; the analyser supplies the raw x_height numbers, not the descriptor.)
Analysing a shipped WOFF2 without converting first
ExampleYou only have the production .woff2 in /public/fonts. Drop it straight in — the analyser decompresses WOFF2 to sfnt in-browser, then reads the metric tables.
Input: Inter-Regular.woff2 (Brotli-wrapped sfnt) Internally: detect magic 'wOF2' → wawoff2.decompress() → flat sfnt buffer → opentype.parse() → read head / OS/2 / hhea Output filename: Inter-Regular.metrics.json (No re-export needed — see ttf-to-woff2 / woff2-to-ttf only if you actually need a converted FONT file, not metrics.)
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 has no OS/2 table at all (some bare TrueType / old Mac fonts)
Partial — typo/win null, hhea usedopentype.js exposes an empty os2 object, so typo.*, win.*, x_height, and cap_height all come back null. The recommended_line_height.typo and .win become null too. Because safe = max(typo ?? 0, win ?? 0, 1.0), you fall back to 1.0 — a tight line that may clip ascenders/descenders. Treat 1.0 as a floor, not a recommendation, for these fonts; you may want to derive a value from the hhea numbers manually.
sxHeight / sCapHeight are 0 or omitted
Preserved as nullOlder OS/2 versions (v0/v1) predate sxHeight and sCapHeight, and many display fonts leave them at zero. The pills then show ? and the JSON carries null. Line-height is unaffected (it doesn't use these), but size-matching and x-height fallback work won't have numbers to use. Measure the actual x and H glyphs in glyph-inspector if you need them.
typo and win disagree by a lot
By design — that's the pointIt's normal for win to be noticeably larger than typo because win bounds must cover diacritics and the tallest glyphs to avoid Windows clipping, while typo reflects the designer's intended leading. The analyser surfaces both rather than averaging, and the safe value takes the larger so nothing clips. Choosing typo is fine on modern browsers if the font sets USE_TYPO_METRICS.
Variable font uploaded
Default-instance metrics onlyopentype.js reads the static OS/2/hhea/head values, which describe the variable font's default instance (e.g. wght 400). Metrics that vary by axis via the MVAR table are not interpolated — the numbers are for the default master. For per-axis metrics, freeze an instance first with variable-font-freezer and re-run the analyser on the frozen file.
TrueType Collection (.ttc) dropped in
RejectedThe format detector identifies .ttc (magic ttcf) but fileToSfntBuffer only handles ttf/otf/woff/woff2 — a collection throws Unsupported font format: ttc. Extract the individual face you want from the collection first, then analyse the single font.
File over the tier size limit
Rejected (limit blocked)A font larger than 5 MB on free (50 MB Pro, 1 GB Developer) is blocked before parsing with an error naming the limit, and the block is recorded for dashboard stats. Large CJK fonts are the usual trigger. Upgrade, or subset the font down with font-subsetter first — subsetting doesn't change the vertical metrics, so the line-height result is identical.
Corrupt or non-font file renamed to .ttf
Error — invalid fontIf the bytes don't match a known font magic, the detector returns unknown and parsing throws before any metric is read. The UI shows the parse error in the red banner. Re-export the font from your editor; a truncated download is the most common cause.
WOFF/WOFF2 with an unusual internal table order
SupportedWOFF2 is decompressed via wawoff2 and WOFF is zlib-inflated and rebuilt into a sorted sfnt before parsing, so the original table order in the web font doesn't matter. The reconstructed sfnt is what opentype.js reads — metrics are identical to analysing the source TTF/OTF.
Negative typo descender vs positive win descent
Handled — absolute value takenBy convention sTypoDescender is stored negative (below baseline) while usWinDescent is stored positive. The formula uses |sTypoDescender| and adds usWinDescent directly, so both produce the correct positive line-box height. You don't need to flip any sign yourself.
Frequently asked questions
Does it read the post table (underline, italic angle)?
No. This tool reads only head (UPM), OS/2 (typo + win + x-height + cap-height), and hhea (ascender/descender/line-gap) — the fields needed for line-height and size-matching. The post table's underlinePosition, underlineThickness, and italicAngle are not in the output. For those, use font-metadata-extractor.
Why are there three sets of ascender/descender?
History. Apple's hhea predates OpenType. Microsoft added OS/2, which ended up with two variants: typo (designer-intended leading) and win (Windows clipping bounds that must include diacritics). Modern fonts populate all three. Which one the browser uses depends on whether the USE_TYPO_METRICS bit (bit 7 of fsSelection) is set. The analyser shows all three so you can pick.
How is the recommended line-height computed exactly?
Two candidates: typo = (sTypoAscender + |sTypoDescender| + sTypoLineGap) / unitsPerEm, and win = (usWinAscent + usWinDescent) / unitsPerEm. The safe value is max(typo, win, 1.0) rounded to three decimals, and that's what the css_recommendation string and the summary pill use. Taking the max guarantees the line box is tall enough that no renderer clips.
What's a typical UPM and does it change my line-height?
TrueType fonts usually use 2048 (sometimes 1024); PostScript/CFF fonts traditionally 1000; most Google Fonts standardise on 1000. UPM is just the internal coordinate scale — it does not change the rendered line-height ratio, because every metric is divided by unitsPerEm. A 2048-UPM and a 1000-UPM font with the same proportions report the same line-height.
Can I analyse a folder of fonts at once?
Not in the web UI — it's strictly one file per run, with no batch or drag-multiple support. To process a directory, run the same opentype.js parse in your own script (see the design-tokens guide), or pair the JAD runner on a paid tier and POST each file to http://127.0.0.1:9789/v1/tools/font-metrics-analyzer/run.
Why does x-height or cap-height show a question mark?
The font left sxHeight / sCapHeight at zero or didn't include them (common in OS/2 v0/v1 fonts and many display faces). The pill shows ? and the JSON carries null. Line-height is unaffected. If you need real x-height/cap-height for those fonts, measure the x and H glyph bounding boxes in glyph-inspector.
Does it work on variable fonts?
It reads the static metric tables, which describe the default instance (typically wght 400). It does not interpolate metrics across axes via MVAR. If a heavier weight has different vertical metrics and you care about them, freeze that instance with variable-font-freezer, then analyse the frozen file.
Is anything uploaded to a server?
No. Parsing runs in your browser via opentype.js (and wawoff2/pako for decompression). The result panel shows a literal '0 bytes uploaded' badge. On paid tiers, if the local runner is paired the same job can run on your machine via the runner — still no font bytes to JAD's servers.
Should I use line-height in px or unitless?
Unitless, always. line-height: 1.15 inherits as a multiplier, so nested elements at smaller font-sizes get proportionally smaller leading. A pixel value like 24px inherits literally and breaks vertical rhythm the moment a child changes font-size. The analyser's css_recommendation is always unitless for this reason.
Can I feed the output straight into a type scale?
Yes — the safe line-height is the base leading; build the size ramp on top of it. typography-scale-builder generates the size steps and clamp-font-size-generator turns a min/max into a fluid clamp(). Keep the analyser's line-height as a separate token so changing the scale ratio doesn't disturb leading.
Does subsetting or converting the font change the metrics?
No. Subsetting removes glyphs and converting to WOFF2 recompresses table bytes, but neither touches head.unitsPerEm or the OS/2/hhea metric fields. You'll get the identical line-height whether you analyse the original TTF or its shipped WOFF2 — so analyse whichever file you have.
What output file do I get?
A JSON file named <fontstem>.metrics.json containing units_per_em, the typo/hhea/win blocks, x_height, cap_height, recommended_line_height (typo/win/safe), and a css_recommendation string. Use Copy to grab the JSON text, or Download to save the file.
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.