How to batch-decompress woff2 to ttf in node and ci
- Step 1Install the same engine the browser uses — `npm install wawoff2`. It ships the Emscripten WASM module the JAD browser tool loads, and works the same in Node 18+. No Python, no native compilation.
- Step 2Walk the WOFF2 directory — `fs.readdir(input)`, filter for `.woff2`. For each file read the bytes and call `await wawoff2.decompress(bytes)`. The call returns the decompressed sfnt as a `Uint8Array`.
- Step 3Detect the flavour from the output magic — Read the first four bytes of the decompressed buffer. `0x00010000` (or `true`) → TrueType → write `.ttf`; `0x4F54544F` (`OTTO`) → CFF → write `.otf`. This mirrors exactly what the browser handler does — do not assume every file is TTF.
- Step 4Make it idempotent — Before decompressing, check whether the target `.ttf`/`.otf` already exists and is newer than the source. Skip if so. This keeps the step cheap on rebuilds where most fonts are unchanged.
- Step 5Bound the concurrency — Run with a small concurrency limit (4–8). Each WASM decompression peaks around 64 MB of WASM memory regardless of font size, and very large fonts can spike higher — too much parallelism risks OOM in a constrained CI runner.
- Step 6Or call the local runner instead — On a paid tier, fetch the schema from `GET /api/v1/tools/woff2-to-ttf`, pair the @jadapps/runner once, then POST each font to `127.0.0.1:9789/v1/tools/woff2-to-ttf/run`. The runner decompresses locally on your machine — nothing is uploaded — and needs no npm install in your pipeline.
Three ways to automate the decompression
All three call the same wawoff2 decompression and produce identical output. The difference is what you have to install and how you drive it.
| Approach | Install | Best for |
|---|---|---|
wawoff2 npm (Node) | npm install wawoff2 | CI steps and migration scripts; byte-identical to the browser tool |
| JAD runner HTTP API | Pair @jadapps/runner (paid tier) | Pipelines that want zero npm/Python deps; processing stays local |
fontTools ttx / fonttools ttLib | pip install fonttools brotli | Teams already on Python who want one toolchain for subset + decompress |
Flavour detection in your script
Read the decompressed buffer's first 4 bytes and branch on the magic — the same logic the browser handler uses to pick the extension.
| Decompressed magic (big-endian u32) | Outline type | Extension + MIME |
|---|---|---|
0x00010000 | TrueType | .ttf / font/ttf |
0x74727565 (true) | TrueType (Apple) | .ttf / font/ttf |
0x4F54544F (OTTO) | CFF / PostScript | .otf / font/otf |
Operational constants for batch runs
Numbers to size your concurrency and tier choice against. Tier limits apply to the browser/runner API; the raw npm package is bound only by your own memory.
| Constant | Value | Implication |
|---|---|---|
| WASM peak memory per decode | ~64 MB baseline | Cap concurrency at 4–8 in constrained CI |
| Per-file size limit (free / Pro / Developer) | 5 MB / 50 MB / 1 GB | Applies to the browser tool and runner API, not raw npm |
| Error on bad bytes | throws (no error correction) | Wrap each decode in try/catch; record and continue |
| Output determinism | byte-identical per input | Safe to cache by source hash; idempotent rebuilds |
Cookbook
Copy-paste starting points for a Node batch, a CI gate, and the runner API. Adapt paths to your project.
Node — decompress one file with flavour detection
ExampleThe core of any batch: decompress, read the output magic, write the right extension. This is exactly what the browser handler does, in Node.
import { promises as fs } from 'node:fs';
import wawoff2 from 'wawoff2';
async function extract(woff2Path) {
const buf = await fs.readFile(woff2Path);
const out = await wawoff2.decompress(buf); // Uint8Array
const magic =
(out[0] << 24) | (out[1] << 16) | (out[2] << 8) | out[3];
const isCff = magic === 0x4f54544f; // 'OTTO'
const ext = isCff ? 'otf' : 'ttf';
const dest = woff2Path.replace(/\.woff2$/i, '.' + ext);
await fs.writeFile(dest, out);
return dest;
}Node — walk a directory, idempotent + bounded concurrency
ExampleSkip fonts already extracted and newer than their source; cap parallelism so a CI runner doesn't OOM.
import { promises as fs } from 'node:fs';
import path from 'node:path';
import wawoff2 from 'wawoff2';
const dir = process.argv[2] ?? './fonts';
const LIMIT = 6;
const files = (await fs.readdir(dir))
.filter(f => /\.woff2$/i.test(f));
let i = 0;
async function worker() {
while (i < files.length) {
const f = files[i++];
const src = path.join(dir, f);
const ttf = src.replace(/\.woff2$/i, '.ttf');
try {
const [s, t] = await Promise.all([
fs.stat(src),
fs.stat(ttf).catch(() => null),
]);
if (t && t.mtimeMs >= s.mtimeMs) continue; // up to date
const out = await wawoff2.decompress(await fs.readFile(src));
const isCff =
out[0]===0x4f && out[1]===0x54 &&
out[2]===0x54 && out[3]===0x4f;
await fs.writeFile(
src.replace(/\.woff2$/i, isCff ? '.otf' : '.ttf'), out);
} catch (e) {
console.error('FAILED', f, e.message);
}
}
}
await Promise.all(Array.from({length: LIMIT}, worker));GitHub Actions — extract for a PDF pipeline
ExampleDecompress production WOFF2 into TTF so a downstream PDF step can embed them. Pure Node, no Python.
- name: Extract WOFF2 -> TTF
run: |
npm install wawoff2
node scripts/extract-fonts.mjs ./dist/fonts
- name: Verify every WOFF2 has a sibling sfnt
run: |
for w in dist/fonts/*.woff2; do
base="${w%.woff2}"
test -f "$base.ttf" || test -f "$base.otf" \
|| { echo "missing sfnt for $w"; exit 1; }
doneJAD runner HTTP API — no npm/Python in the pipeline
ExampleOn a paid tier, POST each font to the local runner. It decompresses with the same wawoff2 module on your machine and returns the sfnt. The response is .ttf or .otf depending on the wrapped flavour.
# Schema: GET /api/v1/tools/woff2-to-ttf curl -sS -X POST \ http://127.0.0.1:9789/v1/tools/woff2-to-ttf/run \ -F 'file=@dist/fonts/Inter-Regular.woff2' \ -o dist/fonts/Inter-Regular.ttf # Output is the decompressed sfnt; if the source # wrapped CFF, rename to .otf per the response.
fontTools alternative (Python teams)
ExampleIf you already run fontTools for subsetting, it reads WOFF2 directly and saves an sfnt. brotli is required to open WOFF2.
# pip install fonttools brotli
from fontTools.ttLib import TTFont
font = TTFont('Inter-Regular.woff2') # reads wOF2
font.flavor = None # drop the woff2 wrapper
font.save('Inter-Regular.ttf') # writes raw sfntEdge 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.
Some files decompress to .otf, not .ttf
By designCFF/PostScript fonts produce an OTTO-magic buffer and must be written as .otf. A script that hardcodes .ttf for every output will mislabel CFF fonts and break strict installers. Always branch on the decompressed magic — 0x4F54544F → .otf — exactly as the browser handler does.
A file in the batch is not actually a WOFF2
throws — catch and continueA WOFF, TTF, or TTC mislabelled .woff2 will not decompress. The browser tool rejects it with Expected a WOFF2 file but detected <FORMAT>; in raw npm, wawoff2.decompress throws. Wrap each decode in try/catch, log the file, and continue the batch rather than aborting the whole run.
Corrupt or truncated WOFF2 in the set
throws — record as a findingWOFF2 has no error correction, so a single bad byte makes wawoff2.decompress throw (ConvertWOFF2ToTTF returned false in the browser). At scale this is common with flaky CDN pulls. Catch it, record the file as failed, and re-fetch from the source — partial recovery is impossible.
CI runner runs out of memory
tune concurrencyEach WASM decode peaks around 64 MB of WASM memory, and large CJK fonts spike higher. Unbounded Promise.all over hundreds of files can OOM a small CI box. Cap concurrency at 4–8 with a worker-pool pattern (as in the cookbook) and the memory stays flat.
Build got slower after adding the step
make it idempotentRe-decompressing every font on every build is wasteful. Skip files whose target .ttf/.otf exists and is newer than the source .woff2 (or cache by source hash). Decompression is deterministic, so a cached result is always valid until the source changes.
Output hash differs from a committed TTF master
Preserved (functionally)WOFF2 normalises table order and padding, so a decompressed sfnt may not byte-match a TTF you committed earlier even though it is the same font. Do not gate CI on hash equality with an old master; compare glyph counts or render, or treat the decompressed sfnt as the new canonical artifact.
Very large CJK font on the browser tool or runner
Rejected — over tier limitThe tier ceiling (5 MB free / 50 MB Pro / 1 GB Developer) applies to the browser tool and runner API. A multi-megabyte CJK WOFF2 may need Pro or Developer. The raw wawoff2 npm package has no such limit — it is bound only by your machine's memory.
Mixing this with subsetting in one pipeline
order mattersIf you also subset, decide order deliberately. To shrink a web asset, subset the sfnt then re-compress with ttf-to-woff2. To recover a desktop/PDF sfnt from a deployed WOFF2, decompress here first; the result reflects whatever subset was already applied — decompression cannot restore removed glyphs.
WASM module init cost per process
amortise itInstantiating the WASM module has a fixed startup cost. In a long-running script the module is loaded once and reused across files, so the cost amortises; in a per-file shell-out (spawning Node per font) you pay it every time. Prefer one process that walks the directory over a process-per-file loop.
Node version too old for the module
use Node 18+The wawoff2 Emscripten module targets modern Node. On very old Node versions the WASM instantiation or top-level await in the example may fail. Run Node 18+ (the same baseline the JAD tooling assumes) and the script behaves like the browser tool.
Frequently asked questions
Is the Node decompression the same as the browser tool?
Yes — both call the wawoff2 package's decompress on the same Emscripten WASM module, so the output bytes are identical. The browser tool exists for security-conscious users who do not want fonts touching any server (even their own); the Node package exists for batch and CI. Same engine, same result, different runtime.
Do I need Python or fontTools?
No. The wawoff2 npm package is pure JS (WASM) and needs no Python, fontTools, or native build chain. fontTools is a fine alternative if your team already runs Python for subsetting — it reads WOFF2 directly (with the brotli package) and can save an sfnt — but it is not required.
How do I set the right output extension?
Read the first four bytes of the decompressed buffer. 0x00010000 (or true) is TrueType → write .ttf; 0x4F54544F (OTTO) is CFF → write .otf. Do not hardcode .ttf — CFF fonts mislabelled .ttf break strict installers. This branch mirrors exactly what the browser handler does.
Can I parallelise the batch?
Yes, with a concurrency cap. Each decode peaks around 64 MB of WASM memory, so use a worker pool with a limit of 4–8 rather than an unbounded Promise.all — otherwise a CI runner with hundreds of fonts can OOM. The cookbook has a bounded worker-pool example.
What is the memory footprint?
WASM peak memory is roughly 64 MB per decode for typical fonts, largely independent of font size, though very large CJK fonts spike higher. A practical single-process ceiling is around a 100 MB WOFF2 input; beyond that, split work across processes. The fixed module-init cost amortises across files in one long-running script.
How do I make the step idempotent?
Before decompressing, check whether the target .ttf/.otf already exists and is newer than the source .woff2; skip if so. Decompression is deterministic — the same input always yields the same bytes — so a cached result stays valid until the source changes. This keeps rebuilds cheap.
Can I run this without installing anything in CI?
On a paid tier, yes — pair the @jadapps/runner once and POST each font to 127.0.0.1:9789/v1/tools/woff2-to-ttf/run (schema at GET /api/v1/tools/woff2-to-ttf). The runner decompresses on your own machine with the same wawoff2 module, so nothing is uploaded and your pipeline needs no npm or Python install.
What happens to a corrupt or non-WOFF2 file in the batch?
wawoff2.decompress throws — for corrupt bytes (no error correction means one bad byte is fatal) and for non-WOFF2 inputs alike. Wrap each decode in try/catch, log the offending filename, and continue the rest of the batch. The browser tool surfaces these as Expected a WOFF2 file but detected … or wawoff2 decompress failed.
Is there a file-size limit when scripting?
The 5 MB / 50 MB / 1 GB tier limits apply to the browser tool and the runner API, not to the raw wawoff2 npm package — when you run the package directly in Node, the only limit is your machine's memory. So for an unbounded local batch, the npm route avoids the tier ceiling entirely.
How does this fit a design-system migration?
A common pattern: recover TTF/OTF masters from a CDN's WOFF2 (this batch), audit licences with font-metadata-extractor, optionally subset, then re-derive web WOFF2 with ttf-to-woff2. Keeping a canonical sfnt master means you never depend on the lossy-to-recover web artifact again.
Will the output be hinted?
Yes if the source was — wawoff2 preserves fpgm/prep/cvt exactly, the same as the browser tool. If your pipeline targets only high-DPI rendering and wants smaller files, drop hinting after decompression with hinting-stripper (or fontTools --no-hinting during subsetting).
Can I verify the batch produced valid fonts?
Add a CI assertion that every .woff2 has a sibling .ttf or .otf (the cookbook has a shell snippet), and optionally re-identify each output's magic with font-format-identifier. For deeper validation, render a sample or check glyph counts — a successful decompress that produced bytes is almost always a valid sfnt.
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.