How to inspect opentype features programmatically in ci
- Step 1Capture a baseline with the browser tool — For each font, run [the OpenType Features Inspector](/font-tools/opentype-features-inspector) and download the `.features.json`. Reduce each to the `features[].tag` array — that's your per-font required set. Commit them as `font-features.required.json`. Capturing the baseline in the browser means the CI script and your manual check start from identical ground truth.
- Step 2Walk the fonts directory in Node — `fs.readdir` the fonts folder. For each `.ttf`/`.otf`/`.woff`/`.woff2`, load it. opentype.js parses TTF/OTF directly; for WOFF/WOFF2 decompress to sfnt first (the browser tool uses pako for WOFF and a wawoff2 WASM build for WOFF2 — replicate that, or pre-convert to TTF in the pipeline).
- Step 3Collect the feature tag set — Parse with opentype.js, read `font.tables.gsub` and `font.tables.gpos`, and collect each `features[].tag` into a `Set`. Union the two sets — that's the deduplicated tag list, identical to what the browser tool's `features` array contains.
- Step 4Diff against the required baseline — For each font, compute `required − present`. If the difference is non-empty, a required feature is missing — log the font and the missing tags. Collect all violations across all fonts before deciding the exit code so one run reports everything.
- Step 5Fail the build on any missing required feature — `process.exit(1)` if any font is missing any required tag. Extra features the font has but the baseline doesn't are fine — the check is for missing required features, not unexpected extras. Print a clear diff so the failing PR shows exactly which tag vanished.
- Step 6Acknowledge legitimate drops in the same PR — When a foundry intentionally removes a feature you no longer need, update `font-features.required.json` in the same PR that bumps the font. That forces an explicit, reviewed acknowledgement of the design impact instead of a silent regression — the whole point of the gate.
Browser tool fields → script data
The CI script reproduces the browser tool's exact output shape, so a manual Inspector run and the automated check never disagree.
| Browser tool field | Script equivalent | Notes |
|---|---|---|
features[].tag | Set union of gsub+gpos feature tags | Deduplicated; this is what you diff against the baseline |
features[].name | Optional label lookup table | Only needed for human-readable logs; the check uses tags |
features[].in_gsub / in_gpos | Membership in each table's tag set | Useful to detect a feature moving tables between versions |
total_features | union.size | A sudden drop in count is itself a useful signal |
(custom feature) fallback | Unknown tag kept verbatim | Don't drop tags you don't recognise — cvNN and private tags matter |
Format handling in Node vs the browser tool
The browser tool decompresses WOFF/WOFF2 to sfnt before parsing. In Node you replicate that or pre-convert. opentype.js itself parses sfnt (TTF/OTF) directly.
| Input | Browser tool | Node script approach |
|---|---|---|
| TTF / OTF | Parsed directly by opentype.js | opentype.parse(buffer) directly — simplest path |
| WOFF | Inflated (zlib) to sfnt, then parsed | Inflate tables with pako, rebuild sfnt, or pre-convert to TTF |
| WOFF2 | Decompressed via wawoff2 WASM, then parsed | Use a WOFF2 decoder (wawoff2/Brotli) or pre-convert to TTF in the pipeline |
| Variable font | Feature tables read regardless of axes | Same — features live in GSUB/GPOS, independent of fvar |
Cookbook
A working feature-regression gate, mirroring the browser tool's logic. The baseline comes from the Inspector; the script enforces it on every build. Keep the two in lockstep so manual and automated checks agree.
Collect features for one font (matches the tool)
ExampleThe core of the browser tool, in plain Node: union the GSUB and GPOS feature tags. TTF/OTF only here — decompress WOFF/WOFF2 first.
import opentype from "opentype.js";
import fs from "node:fs";
function featureTags(path) {
const font = opentype.parse(fs.readFileSync(path).buffer);
const tags = new Set();
for (const tbl of [font.tables.gsub, font.tables.gpos]) {
for (const f of tbl?.features ?? []) if (f?.tag) tags.add(f.tag);
}
return [...tags].sort();
}
console.log(featureTags("./fonts/Brand-Regular.ttf"));
// → ['calt','kern','liga','smcp','ss01','tnum'] (same as the tool)The required-features baseline
ExampleOne entry per font, the tags that must be present. Captured from the Inspector's features[].tag arrays and committed to the repo.
// font-features.required.json
{
"Brand-Regular.ttf": ["kern", "liga", "calt", "tnum", "smcp"],
"Brand-Bold.ttf": ["kern", "liga", "calt", "tnum"],
"Brand-Mono.ttf": ["kern", "zero"]
}The CI gate — fail on any missing required feature
ExampleWalk the directory, diff each font against its required set, collect all violations, exit non-zero if any. Extra features are allowed; missing required ones fail.
import fs from "node:fs";
import path from "node:path";
const required = JSON.parse(fs.readFileSync("font-features.required.json"));
let failed = false;
for (const file of fs.readdirSync("./fonts")) {
if (!/\.(ttf|otf)$/i.test(file)) continue; // WOFF2: pre-convert
const need = required[file];
if (!need) continue;
const have = new Set(featureTags(path.join("./fonts", file)));
const missing = need.filter((t) => !have.has(t));
if (missing.length) {
failed = true;
console.error(`✗ ${file} missing: ${missing.join(", ")}`);
} else {
console.log(`✓ ${file}`);
}
}
process.exit(failed ? 1 : 0);Detecting a feature moving between GSUB and GPOS
ExampleRarely a font version moves a tag from one table to the other. Track in_gsub/in_gpos per tag to catch shaping-relevant moves, not just presence.
function featureMap(path) {
const font = opentype.parse(fs.readFileSync(path).buffer);
const map = {};
for (const f of font.tables.gsub?.features ?? []) (map[f.tag] ??= {}).gsub = true;
for (const f of font.tables.gpos?.features ?? []) (map[f.tag] ??= {}).gpos = true;
return map;
}
// Diff featureMap(old) vs featureMap(new):
// kern: was {gpos:true}, now {gsub:true,gpos:true} → reviewRun via the JAD runner instead of bundling opentype.js
ExampleJAD's hosted API never accepts uploads. To run the inspector through your paired @jadapps/runner, fetch the schema, then POST to the local runner endpoint — the font is processed on your machine.
# 1. Inspect the schema (no upload):
curl -H "Authorization: Bearer $JAD_API_KEY" \
https://<host>/api/v1/tools/opentype-features-inspector
# 2. Execute locally through the paired runner:
curl -X POST \
-F file=@./fonts/Brand-Regular.ttf \
http://127.0.0.1:9789/v1/tools/opentype-features-inspector/run
# → same JSON: { total_features, gsub_count, gpos_count, features }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.
opentype.js groups features by script in some versions
Version-dependentAcross opentype.js versions the GSUB/GPOS shape varies — most expose a flat features array on the table, which is what both the browser tool and these scripts read. If a version groups features under scripts[].script.langSysRecords, the flat features list is still the authoritative deduplicated set the tool uses. Pin your opentype.js version in CI so the parse shape can't shift under you between builds.
WOFF2 won't parse with bare opentype.js in Node
Decompress firstopentype.js parses sfnt (TTF/OTF). A WOFF2 file is Brotli-compressed and won't parse directly — you'll get a parse error. The browser tool decompresses WOFF2 with a wawoff2 WASM build first. In Node, either run a WOFF2 decoder before opentype.parse, or pre-convert fonts to TTF in the pipeline. Filtering the CI loop to .ttf/.otf (as the example does) sidesteps this for source fonts.
Passing check, but the glyphs are gone
Limited guaranteeThe feature tag being present in GSUB does not prove the substitution glyphs still exist or that coverage is unchanged. A font could keep the smcp tag while dropping half its small-cap glyphs. This gate catches a removed *feature*, not degraded *coverage*. For coverage regressions, add a glyph-count or character-coverage check alongside it (glyph count analyzer, character coverage map).
Unknown tag in the baseline
PreservedIf your required set includes cv11 or a private tag, the script must keep unknown tags verbatim — exactly as the browser tool labels them (custom feature) but still emits them. Don't filter to a known-tag allowlist; that would drop legitimate cvNN and foundry-private features from both capture and check.
Font renamed between versions
Baseline missThe baseline is keyed by filename. If a font update renames Brand-Regular.ttf to Brand-Text.ttf, the script finds no required entry for the new name and skips it (no failure) while the old key never matches. Normalise on family/subfamily from the name table instead of filename if your foundry renames files, or update the baseline keys in the version-bump PR.
Extra features added by the foundry
AllowedA new font version that *adds* features (more stylistic sets, a new zero) passes the gate unchanged — the check is required − present, so extras are fine. File size grows slightly. If you want to be notified of additions too (to consider exposing them), diff total_features against the previous run and log increases as informational, not failures.
Variable font instanced before the check
Same resultFeature tables live in GSUB/GPOS, independent of fvar, so a variable font reports the same feature set whether you check the variable file or a static instance produced by the variable font freezer. The exception is rvrn (Required variation alternates), which only matters for variable files — keep it in the baseline for the .var font, not its frozen instances.
Corrupt or non-font file in the directory
Error — handle itA truncated download or a stray .DS_Store renamed to .ttf makes opentype.parse throw. Wrap the per-font parse in try/catch and treat a parse failure as a hard CI failure (a font you can't read is a font you can't ship), logging the filename so the bad artifact is obvious. The browser tool surfaces the same condition as an explicit error rather than an empty list.
Frequently asked questions
Are some features guaranteed to be present in every font?
kern is near-universal in modern fonts (or at least a GPOS pair-positioning lookup); liga is in the vast majority of Latin fonts; calt is common in modern OpenType. Beyond those three, support varies wildly. Don't assume — pin requirements per font in the baseline based on what the Inspector actually reports for that specific file, and let the CI gate enforce them.
What if my design system doesn't use a feature the foundry ships?
Extra features are harmless to the check — the gate only fails on *missing required* features, never on unexpected extras. Unused features cost a little file size but nothing at render time. If you want to trim them, subset with the font subsetter, but verify with the Inspector that no role depends on them first.
Can I lint font-feature-settings usage in my CSS too?
Yes — extend the script: grep your CSS for font-feature-settings tags, then for each selector verify every tag is present in every font that could be the active typeface there. It's more involved (you have to resolve which font applies to which selector), but it catches a CSS rule that enables a feature the font doesn't have — the silent-no-op bug. Useful for large design systems.
Does the Node script give the same answer as the browser tool?
Yes, by design — both union the GSUB and GPOS features[].tag sets via opentype.js and deduplicate. The browser tool adds human labels and CSS snippets; the CI script only needs the tags for the diff. Capturing the baseline from the browser tool and enforcing it with the script keeps manual and automated checks in lockstep.
How do I handle WOFF and WOFF2 in CI?
opentype.js parses sfnt (TTF/OTF) directly but not compressed WOFF/WOFF2. The browser tool decompresses WOFF with pako (zlib) and WOFF2 with a wawoff2 WASM build before parsing. In Node, either replicate that decompression, or pre-convert fonts to TTF earlier in the pipeline and run the check on the TTFs. Filtering the loop to .ttf/.otf is the simplest approach when your source fonts are uncompressed.
Should the build fail on extra features or only missing ones?
Only missing required features should fail — that's a regression. Extra features are additive and harmless. If you want visibility into additions (maybe a new stylistic set worth exposing), log a total_features increase as informational output without failing the build. Keep failures reserved for genuine losses against the baseline.
How do I capture the initial baseline?
Run each font through the OpenType Features Inspector, download the .features.json, and reduce each to its features[].tag array. That becomes one entry in font-features.required.json. Doing the capture in the browser means your baseline is the exact tag set the tool reports, so the CI check can never drift from a manual spot-check.
Can I run the inspector through JAD's API in CI?
JAD's hosted API never accepts uploads — GET /api/v1/tools/opentype-features-inspector returns the schema only. To execute it on a font, pair an @jadapps/runner and POST to http://127.0.0.1:9789/v1/tools/opentype-features-inspector/run; the font is processed locally on your machine and you get back the same JSON. For a self-contained CI step with no runner, calling opentype.js directly (as in the examples) is simplest.
Does a passing check mean the font's typography is unchanged?
No — it means the required feature *tags* are still declared. A font could keep smcp while dropping small-cap glyphs, or change metrics, or alter outlines. Layer additional gates: glyph count via the glyph count analyzer, coverage via the character coverage map, and metrics via the font metrics analyzer. Together they form a real font-quality gate.
Why pin the opentype.js version in CI?
Because the GSUB/GPOS parse shape has varied across opentype.js versions — some expose a flat features array, some group by script. Pinning the version means the parse result can't shift between builds, so a green check stays meaningful. Treat the parser version as part of your baseline's contract and bump it deliberately, re-validating the baseline when you do.
Is this idempotent and safe to gate every PR?
Yes. The script reads fonts and compares against a committed baseline; it writes nothing and has no side effects, so re-running with unchanged fonts produces the same pass/fail every time. That makes it safe to wire into every PR as a required status check — fast, deterministic, and only noisy when something actually regressed.
What other font audits pair well with this in CI?
Run feature inspection alongside metadata extraction (font metadata extractor) to catch license/version drift, the font metrics analyzer for line-height-affecting metric changes, and the glyph count analyzer for coverage loss. Together they turn 'we bumped the font' from a leap of faith into a reviewed, gated change.
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.