How to opentype metrics tables: complete reference
- Step 1Start with head — the coordinate scale — `head.unitsPerEm` (the analyser's `units_per_em`) is the scale every other metric is expressed in: 2048 for most TrueType, 1000 for CFF/PostScript. The analyser reads only UPM from `head`; it does not surface `head`'s glyph bounding box (`xMin`/`yMin`/`xMax`/`yMax`) or version.
- Step 2Read hhea — the Apple-era line metrics — `hhea.ascender`, `hhea.descender`, `hhea.lineGap` (the analyser's `hhea` block). Apple's original horizontal header; some legacy renderers and pre-2015 macOS used these for line layout. Modern browsers prefer OS/2 typo when the flag is set.
- Step 3Read OS/2 typo — the designer's intent — `sTypoAscender`, `sTypoDescender` (negative), `sTypoLineGap` (the analyser's `typo` block). These are the designer-specified leading metrics, used by browsers when `USE_TYPO_METRICS` is set. Also here: `sxHeight` and `sCapHeight`, which the analyser reports as `x_height` / `cap_height`.
- Step 4Read OS/2 win — the Windows clipping bounds — `usWinAscent`, `usWinDescent` (both positive, the analyser's `win` block). These define the box outside which Windows historically clipped glyphs, so they must cover the tallest ascenders and lowest descenders including diacritics — usually larger than typo.
- Step 5Know what the analyser leaves to post and others — The `post` table holds `underlinePosition`, `underlineThickness`, `italicAngle`, `isFixedPitch` — the analyser does not read it. Glyph count lives in `maxp`; per-glyph geometry in `glyf`/`CFF`. Use the sibling tools for those.
- Step 6Map it back to the analyser's JSON — `units_per_em` ← head; `typo.*` ← OS/2 sTypo; `win.*` ← OS/2 usWin; `hhea.*` ← hhea; `x_height`/`cap_height` ← OS/2 sxHeight/sCapHeight; `recommended_line_height` is computed, not stored in any table.
The OpenType metric tables and what they hold
Four homes for vertical metrics plus post for underline/angle. The 'Analyser reads?' column is the load-bearing one for understanding the JSON.
| Table.field | Holds | Analyser reads? | JSON key (if read) |
|---|---|---|---|
head.unitsPerEm | Coordinate scale (1000 / 2048) | Yes | units_per_em |
head.xMin/yMin/xMax/yMax | All-glyph bounding box | No | — |
hhea.ascender/descender/lineGap | Apple-era line metrics | Yes | hhea.ascender/descender/line_gap |
OS/2.sTypoAscender/Descender/LineGap | Designer-intended leading (descender negative) | Yes | typo.ascender/descender/line_gap |
OS/2.usWinAscent/usWinDescent | Windows clipping bounds (both positive) | Yes | win.ascent/descent |
OS/2.sxHeight / sCapHeight | x-height / cap-height | Yes | x_height / cap_height |
OS/2.fsSelection bit 7 | USE_TYPO_METRICS flag | No (not surfaced) | — |
post.underlinePosition/Thickness/italicAngle | Underline + slant | No | — (see font-metadata-extractor) |
maxp.numGlyphs | Glyph count | No | — (see glyph-count-analyzer) |
Which renderer prefers which metric set
The reason a font's line box differs across platforms. Behaviour is gated by the USE_TYPO_METRICS bit; the analyser shows all sets so you can predict the outcome.
| Renderer / context | Preferred metrics | Notes |
|---|---|---|
| Modern browsers, USE_TYPO_METRICS set | OS/2 typo | Designer's intended leading; most fonts since ~2015 set the flag |
| Modern browsers, flag unset | OS/2 win | Often a taller line box (diacritic clearance) |
| Windows clipping (historical) | OS/2 win | Glyphs outside usWin bounds were clipped |
| Pre-2015 macOS / some legacy | hhea | Apple's original line metrics |
Sign conventions you must respect
Mixing these up is the most common hand-calculation bug. The analyser already handles the signs in its formula.
| Field | Sign | In the line-height formula |
|---|---|---|
sTypoAscender | Positive (above baseline) | Added directly |
sTypoDescender | Negative (below baseline) | Absolute value taken |
sTypoLineGap | Positive (extra leading) | Added directly |
usWinAscent | Positive | Added directly |
usWinDescent | Positive (already) | Added directly (no flip) |
Cookbook
Concrete field readings mapped to the analyser's JSON, plus where to look for the fields it doesn't read. For the line-height math built on these fields see the line-height guide; to automate reading them see the design-tokens guide.
Full field-to-JSON mapping for one font
ExampleEvery value the analyser emits, traced back to its source table field. Note recommended_line_height is computed, not stored anywhere.
OpenType tables → Analyser JSON ───────────────────────────────────────────────── head.unitsPerEm = 2048 → units_per_em: 2048 OS/2.sTypoAscender = 1984 → typo.ascender: 1984 OS/2.sTypoDescender = -494 → typo.descender: -494 OS/2.sTypoLineGap = 0 → typo.line_gap: 0 OS/2.usWinAscent = 2189 → win.ascent: 2189 OS/2.usWinDescent = 600 → win.descent: 600 hhea.ascender = 1984 → hhea.ascender: 1984 hhea.descender = -494 → hhea.descender: -494 hhea.lineGap = 0 → hhea.line_gap: 0 OS/2.sxHeight = 1118 → x_height: 1118 OS/2.sCapHeight = 1490 → cap_height: 1490 (computed) → recommended_line_height.safe: 1.362
Why typo and win differ on the same font
Examplewin bounds must clear diacritics so Windows doesn't clip them; typo is the designer's tighter intended leading. Same font, two answers.
typo line box = (1984 + 494 + 0) / 2048 = 1.210 win line box = (2189 + 600) / 2048 = 1.362 Difference = 0.152 → a paragraph rendered with win metrics is ~15% taller per line than one rendered with typo metrics. The analyser reports both; 'safe' = 1.362 (the larger).
Reading USE_TYPO_METRICS yourself
ExampleThe analyser doesn't surface the flag, but it determines which metric set the browser uses. Check bit 7 of fsSelection in your own script.
// opentype.js const fsSel = font.tables.os2.fsSelection; const useTypoMetrics = (fsSel & 0x80) !== 0; // bit 7 console.log(useTypoMetrics ? "Browser uses OS/2 typo metrics" : "Browser may use OS/2 win metrics"); // → tells you which of the analyser's blocks the browser will honour
Where the post-table fields live (not in this tool)
ExampleUnderline and italic angle are real OpenType metrics, but in the post table the analyser doesn't read. Use the metadata extractor or read them directly.
post.underlinePosition = -217 // not in analyser JSON post.underlineThickness = 150 // not in analyser JSON post.italicAngle = 0 // not in analyser JSON post.isFixedPitch = 0 // not in analyser JSON → Get these from font-metadata-extractor, or in opentype.js: font.tables.post.underlinePosition /* etc. */
head bounding box vs the analyser's scope
Examplehead carries an all-glyph bbox, but the analyser only takes unitsPerEm from head. The bbox isn't in the JSON.
head.unitsPerEm = 2048 → units_per_em: 2048 (read) head.xMin = -1361 → not in JSON head.yMin = -555 → not in JSON head.xMax = 4096 → not in JSON head.yMax = 2163 → not in JSON → For per-glyph bounding boxes use glyph-inspector; for the glyph count use glyph-count-analyzer.
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 with no OS/2 table
typo/win/x/cap become nullOS/2 is optional in bare TrueType. Without it the analyser's typo, win, x_height, and cap_height are all null, and only the hhea block has numbers. recommended_line_height then floors at 1.0. Read the hhea block to recover a usable line-height. This is common in old Mac-only fonts and some hand-built tools' output.
OS/2 version 0 or 1 (no sxHeight / sCapHeight)
x/cap null, rest finesxHeight and sCapHeight were added in OS/2 version 2. Fonts at version 0/1 simply don't have them, so the analyser reports null for x_height/cap_height while typo/win/line-height work normally. The font isn't broken — those fields just didn't exist when its OS/2 version was authored.
typo equals hhea exactly
Expected for many fontsPlenty of fonts copy the same numbers into hhea and OS/2 typo (e.g. ascender 1984 in both). Seeing identical typo and hhea blocks is normal and means the designer kept the two in sync. The analyser still reports both so you can confirm they match rather than assuming.
usWinDescent stored positive but you expected negative
By designUnlike sTypoDescender (negative below baseline), usWinDescent is stored as a positive magnitude. The analyser reports it as-is and adds it directly in the win formula. If you hand-calculate, don't negate it — that double-negative is a frequent line-height bug.
Looking for underline thickness in the output
Not present — wrong tool pathUnderline position/thickness and italic angle are in post, which this analyser doesn't read, so they're absent from the JSON by design. Get them from font-metadata-extractor or read font.tables.post yourself. The metrics analyser is scoped to line-height and size-matching fields only.
Variable font's metrics look static
Default-instance valuesThe analyser reports OS/2/head values for the default instance; it does not interpolate the MVAR deltas that let metrics vary by axis. So a wght 900 instance may render with different effective metrics than the reported wght 400 numbers. Freeze the instance with variable-font-freezer to read its baked metrics.
fsSelection flag not in the JSON
Not surfaced — read it separatelyThe USE_TYPO_METRICS bit (fsSelection bit 7) decides whether a browser uses typo or win, but the analyser doesn't expose it. To know which of the reported blocks the browser will actually honour, read font.tables.os2.fsSelection & 0x80 in your own script (see the cookbook). The analyser gives you both blocks regardless.
WOFF/WOFF2 input — same tables, decompressed
SupportedWOFF2 (Brotli) and WOFF (zlib) wrap the same sfnt tables. The analyser decompresses to a flat sfnt before reading, so the head/OS/2/hhea values are identical to analysing the source TTF/OTF. The wrapper format never changes the metric numbers — only how the bytes were stored.
Frequently asked questions
Why are there three sets of metrics?
Historical layering. Apple's hhea came first. Microsoft added OS/2, which ended up with two variants: sTypo* for the designer's intended leading and usWin* for Windows clipping bounds (which must clear diacritics). Modern fonts populate all three; the renderer picks one based on the USE_TYPO_METRICS flag. The analyser shows all three so you can see why a font behaves differently across platforms.
What is USE_TYPO_METRICS?
Bit 7 of the OS/2.fsSelection field. When set, modern browsers and OSes use the sTypo* (designer-intended) metrics; when unset, they fall back to usWin* (usually a taller line box). Most fonts since around 2015 set it. The analyser doesn't surface the flag directly — read fsSelection & 0x80 yourself to know which set the browser will honour.
Which tables does the analyser actually read?
head (only unitsPerEm), OS/2 (sTypoAscender/Descender/LineGap, usWinAscent/Descent, sxHeight, sCapHeight), and hhea (ascender/descender/lineGap). It computes line-height from those. It does not read post, the head bounding box, maxp glyph count, or per-glyph geometry — those are out of scope for this tool.
Why isn't underline position or italic angle in the output?
Those live in the post table, which the metrics analyser doesn't read — its scope is the vertical metrics that drive line-height and size-matching. For underlinePosition, underlineThickness, italicAngle, and isFixedPitch, use font-metadata-extractor or read font.tables.post directly.
Why does line-height differ between browsers for the same font?
Because the browser computes the natural line box from whichever metric set it prefers — typo if USE_TYPO_METRICS is set, otherwise win — and those sets often differ. A font with win bounds 15% taller than its typo bounds gets a noticeably taller line box on a flag-unset browser. Set an explicit line-height (use the analyser's safe value) to remove the ambiguity.
What's a typical unitsPerEm and where is it stored?
It's in head.unitsPerEm. TrueType fonts usually use 2048 (sometimes 1024); CFF/PostScript fonts traditionally 1000; many Google Fonts standardise on 1000. UPM is the coordinate scale every other metric is expressed in — it doesn't change how big the font renders, only the internal numbers, which is why the analyser divides every metric by it.
Is the descender positive or negative?
sTypoDescender (OS/2 typo) and hhea.descender are negative — they point below the baseline. usWinDescent (OS/2 win) is stored as a positive magnitude. The analyser reports each as-is and uses |sTypoDescender| plus a direct usWinDescent in its formulas, so the line box comes out correct. If you hand-calculate, mind the difference.
What if my font has no OS/2 table at all?
Then typo, win, x_height, and cap_height are all null and the analyser falls back to a 1.0 line-height floor. The hhea block still has values — compute (hhea.ascender + |hhea.descender| + hhea.lineGap) / units_per_em from it for a usable line-height. Bare TrueType and some legacy fonts hit this.
Where do I get the glyph count or per-glyph metrics?
Not from this tool. Glyph count is in maxp — use glyph-count-analyzer. Per-glyph bounding boxes and outlines come from glyf/CFF — use glyph-inspector, which now emits a real per-glyph SVG path, bbox, and viewBox. The metrics analyser is font-wide vertical metrics only.
Does the WOFF/WOFF2 wrapper change the metric values?
No. WOFF and WOFF2 are compression wrappers around the same sfnt tables. The analyser decompresses to a flat sfnt before reading head/OS/2/hhea, so the numbers are identical to the source TTF/OTF. Analyse whichever file you have on hand.
How do I confirm which metric block the browser will use?
Read the USE_TYPO_METRICS flag: font.tables.os2.fsSelection & 0x80. If set, the browser uses the analyser's typo block; if not, expect win. The analyser reports both so that, whichever way the flag goes, you already have the numbers — and you can pin an explicit line-height to override the choice entirely.
Can I trust the analyser's safe value across all platforms?
Yes — that's its purpose. safe = max(typo, win, 1.0) takes the larger of the two metric-derived line boxes, so a renderer that picks either set will have enough room and won't clip ascenders or descenders. The trade-off is a slightly looser line on whichever platform would otherwise have used the smaller value; pin typo explicitly if you'd rather have the tighter leading and accept the platform assumption.
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.