How to strip font hinting automatically in ci/cd
- Step 1Enumerate source fonts — `fs.readdir` your `src/fonts/` directory and filter to `.ttf`/`.otf`/`.woff`/`.woff2`. The browser UI is single-file by design, so your script is the thing that iterates — there is no built-in batch mode to call.
- Step 2Decompress to a flat sfnt if needed — TTF/OTF pass through; WOFF needs zlib inflate per table; WOFF2 needs a Brotli decode (e.g. `wawoff2`). This mirrors the tool's `fileToSfntBuffer` step — strip operates on a flat sfnt, never on the compressed wrapper.
- Step 3Remove the seven tables and rebuild the directory — Parse the 12-byte sfnt header + 16-byte directory entries, drop any whose tag is in `{fpgm, prep, cvt , gasp, hdmx, VDMX, LTSH}`, sort the survivors by tag, recompute `searchRange`/`entrySelector`/`rangeShift`, and re-pack table data on 4-byte boundaries. That is exactly what the tool's `removeSfntTables` does.
- Step 4Write the unhinted TTF — Output is always a raw TTF (the tool names it `*.unhinted.ttf`). If the input was a WOFF2, you now have a much larger raw file — that's expected and gets fixed in the compression stage.
- Step 5Re-subset and re-compress — Chain [font-subsetter](/font-tools/font-subsetter) (or `fonttools subset`) to drop unused glyphs, then [TTF → WOFF2](/font-tools/ttf-to-woff2) (or `wawoff2`) to Brotli-compress. This is where the shipping size actually lands.
- Step 6Validate and cache — Parse each output with opentype.js to confirm it still loads. Key your build cache on a hash of the source directory so unchanged fonts skip the whole pipeline. Optionally run `fonttools` once to fix the stale `head` checksum if your QA gate is strict.
Browser tool vs. your CI script
What the hosted tool does and what you must own in automation. The strip logic is identical; orchestration is on you.
| Concern | Browser tool | Your CI step |
|---|---|---|
| Tables removed | fpgm, prep, cvt , gasp, hdmx, VDMX, LTSH (fixed) | Mirror the same seven tags exactly |
| Batch | One file at a time (UI slices to a single file) | You iterate the directory yourself |
| Input formats | TTF/OTF/WOFF/WOFF2 (auto-decompressed) | Same — handle wrapper decode in your loader |
| Output | Always raw .unhinted.ttf, font/ttf | Same — then pipe to WOFF2 yourself |
| Options | None | None to mirror — don't add per-table flags expecting tool parity |
| head checksum | Not recomputed | Optionally fix with fonttools for strict validators |
Recommended build pipeline stages
Linear, one job per font weight. Each stage maps to a JAD tool or its CLI equivalent.
| Stage | Operation | Tool / equivalent | Typical effect |
|---|---|---|---|
| 1 | Strip hinting | hinting-stripper / removeSfntTables | −5% to −20% |
| 2 | Subset glyphs | font-subsetter / fonttools subset | Large, depends on charset |
| 3 | Compress | TTF → WOFF2 / wawoff2 | −35% to −50% vs raw TTF |
| (opt) | Scrub names | name-table-cleaner | Small; removes vendor strings |
Per-tier limits relevant to a runner
If you use the hosted tool from a paid account or its runner, these are the single-file ceilings. CI usually runs the equivalent logic locally with no such cap.
| Tier | Max file | Files per UI run |
|---|---|---|
| Free | 5 MB | 1 |
| Pro | 50 MB | 1 |
| Developer | 1 GB | 1 |
Cookbook
Drop-in snippets for an npm prebuild or GitHub Actions step. The strip is plain ArrayBuffer work; the surrounding stages are the usual subset + WOFF2.
Minimal Node strip (mirrors removeSfntTables)
ExampleRemove the seven hint tables from a flat TTF sfnt and rebuild the directory. This is the same algorithm the browser tool runs.
const HINT_TAGS = new Set(['fpgm','prep','cvt ','gasp','hdmx','VDMX','LTSH']);
function stripHinting(buf) {
const dv = new DataView(buf);
const numTables = dv.getUint16(4, false);
const keep = [];
for (let i = 0; i < numTables; i++) {
const o = 12 + i*16;
const tag = String.fromCharCode(...new Uint8Array(buf, o, 4));
if (!HINT_TAGS.has(tag)) keep.push({ off:o, tag });
}
// rebuild header + directory + 4-byte-padded table data from `keep` ...
// (sort by tag, recompute searchRange/entrySelector/rangeShift)
}Directory walk (the batch the UI won't do)
ExampleThe browser tool is single-file. In CI you own the loop over every weight.
import { readdir, readFile, writeFile } from 'node:fs/promises';
const files = (await readdir('src/fonts'))
.filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
for (const f of files) {
const sfnt = await toSfnt(await readFile(`src/fonts/${f}`)); // decode woff/woff2
const out = stripHinting(sfnt);
await writeFile(`dist/fonts/${f.replace(/\.\w+$/, '.unhinted.ttf')}`, out);
}Compose strip → subset → WOFF2
ExampleThe strip alone yields a raw TTF. The shipping size comes from the next two stages.
# pseudo-pipeline per weight strip brand.ttf -> brand.unhinted.ttf # -12% subset brand.unhinted.ttf -> brand.subset.ttf # latin only woff2 brand.subset.ttf -> brand.subset.woff2 # -40% # ship only brand.subset.woff2
GitHub Actions prebuild step
ExampleRun the font pipeline before the app build so /public/fonts is always optimised.
- name: Optimise fonts run: node scripts/fonts.mjs # strip -> subset -> woff2 - name: Build app run: npm run build # cache key on hash of src/fonts so unchanged fonts are skipped
Validate output still parses
ExampleA cheap gate that catches a malformed source font before it ships.
import opentype from 'opentype.js';
const font = opentype.parse(out.buffer); // throws on bad sfnt
if (font.numGlyphs < 1) throw new Error('empty font after strip');
// Note: opentype ignores head.checkSumAdjustment, so a stale
// checksum won't fail here — run fonttools if QA needs it clean.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.
Expecting a built-in batch mode in the hosted tool
By designThe web UI processes one file per run — it slices the picked file list down to a single file even on Pro/Developer. There is no multi-file queue to call from a script. Batching is something your CI loop owns; the tool is the single-file primitive.
WOFF2 in, raw TTF out — pipeline forgets to re-compress
ExpectedStrip always emits raw TTF. If your CI stops after the strip, you've shipped a decompressed, larger font than you started with. The WOFF2 stage is mandatory, not optional — make it part of the same job.
OTF input passes through unchanged
PreservedCFF fonts have no fpgm/prep/cvt , so the strip removes nothing and returns identical bytes — but renamed to .unhinted.ttf. If your build keys on extension, account for OTF→.ttf renaming, or branch on the OTTO magic and skip OTF entirely.
Idempotent re-run on cached output
SupportedRunning the strip on an already-stripped font finds none of the seven tags and returns a byte-identical copy. This makes caching safe: a no-op re-run won't churn your artifacts. Key your cache on the source hash and skip unchanged inputs entirely.
Strict validator flags head checksum
Checksum warningThe directory rebuild changes offsets but leaves head.checkSumAdjustment stale. Renderers ignore it; fontbakery/OTS-strict modes may warn or fail your gate. Fix by piping the output through fonttools once, or relax that specific check in CI.
Variable font in the pipeline
SupportedVariable TTFs get their hint tables stripped while fvar/gvar/STAT survive, so they stay variable. Don't confuse this with instancing — to pin axes, run variable-font-freezer or fonttools varLib.instancer as a separate stage.
TTC collection in the source directory
Unsupported formatA .ttc is rejected (Unsupported font format: ttc). The tool only handles single faces. In CI, split collections with fonttools ttLib first, then strip each extracted face.
Non-font masquerading as .ttf
InvalidA file with a font extension but wrong magic bytes fails the format check rather than producing garbage. In CI, validate the magic (0x00010000, OTTO, wOFF, wOF2) at the loader stage so a bad asset fails fast with a clear error.
Adding per-table flags expecting tool parity
Not supportedThe hosted tool has no per-table options — it's all seven tags or nothing. If your CI keeps cvt (a niche choice), that's a deliberate divergence from the tool; document it so reviewers know your output won't match the web tool's byte-for-byte.
Glyph outlines unexpectedly changed
Won't happenThe strip only removes hint/metric tables; glyf/loca and CFF are copied verbatim. If outlines differ, the change came from a different stage (subsetting, instancing) — not from stripping. Bisect by diffing outputs after each stage.
Frequently asked questions
Is there a batch mode I can call?
Not in the hosted UI — it processes one file per run, slicing the selection to a single file even on paid tiers. In CI you write the loop over your fonts directory yourself; the tool is the single-file building block.
Which tables must my script remove to match the tool?
Exactly seven: fpgm, prep, cvt (trailing space), gasp, hdmx, VDMX, LTSH. Remove those, sort the survivors by tag, and rebuild the sfnt directory — that reproduces the tool's output.
Do I need native binaries in the runner?
Not for the strip itself — it's pure ArrayBuffer surgery. You'll want a Brotli encoder (wawoff2) for the WOFF2 stage and optionally fonttools for subsetting and checksum fixes, but the strip has no native dependency.
Why is my stripped WOFF2 source now huge?
Because the strip outputs raw TTF, undoing the WOFF2 Brotli compression. Always follow the strip with a WOFF2 re-compress stage; that's where the real shipping size comes from.
Does the script work on OTF (CFF) fonts?
It runs but is effectively a no-op: CFF fonts have none of the seven tables, so the bytes are unchanged. Just be aware the tool renames the output to .ttf regardless — handle that if your build branches on extension.
Is the output deterministic for caching?
Yes. The strip is a pure function of the input bytes (offsets and the directory are rebuilt deterministically). The only non-recomputed field is head.checkSumAdjustment, which is copied through unchanged, so identical input gives identical output.
Can I keep some hinting selectively in CI?
You can in your own script (e.g. drop fpgm/prep but keep cvt ), but that diverges from the hosted tool, which always removes all seven. Document any divergence so reviewers don't expect byte-for-byte parity with the web tool.
How do I handle WOFF and WOFF2 inputs?
Decompress to a flat sfnt first — zlib-inflate each table for WOFF, Brotli-decode for WOFF2 — then strip. This mirrors the tool's fileToSfntBuffer step. Strip never operates on the compressed wrapper.
What about CI caching overhead?
Key the build cache on a hash of the source fonts directory. Unchanged fonts skip the entire strip-subset-compress pipeline. Because the strip is idempotent, even a cache miss that re-runs on stripped output is safe.
How do I prove the output is still a valid font?
Parse it with opentype.js (it throws on a malformed sfnt) and assert numGlyphs > 0. For a stricter gate, run fonttools or OTS — just expect a possible head checksum note unless you fix it.
Will validators fail because of the checksum?
Some strict ones might warn — the directory rebuild leaves head.checkSumAdjustment stale. Renderers ignore it. If your gate is strict, pipe the output through fonttools once to recompute it.
What's the recommended stage order?
Strip → subset → WOFF2. Strip first so subsetting works on a smaller, hint-free table set; compress last so the final artifact is the smallest. Optionally insert name-table-cleaner before compression to drop vendor strings.
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.