How to batch convert ttf to woff in a build script
- Step 1Pick an engine — `pako` in Node mirrors the browser tool exactly: read the sfnt, deflate each table, keep the smaller of compressed/original, write the WOFF header. Or skip the code entirely and POST each font to the runner endpoint `http://127.0.0.1:9789/v1/tools/ttf-to-woff/run`.
- Step 2Walk the fonts directory — Use `fs.readdir` to find every `*.ttf` and `*.otf` under `src/fonts/`. The converter detects format from magic bytes, so the extension filter is just a convenience — anything sfnt works.
- Step 3Convert each file to WOFF — For the pako path, wrap the sfnt header (44 bytes), write a 20-byte directory entry per table, and append each table's deflated (or raw, if smaller) data. For the runner path, `curl -F file=@…` per font and write the response body.
- Step 4Make reruns idempotent — Before converting, compare the source mtime (or a content hash) against the existing `.woff`. Skip if unchanged. This keeps the prebuild fast on the common case where only one weight changed.
- Step 5Wire it into prebuild — Add the script to `npm run prebuild` (or a dedicated `fonts` script) so it runs before every build. Run the WOFF and WOFF2 conversions together as one step, both writing to `public/fonts/`.
- Step 6Add a CI guard — In GitHub Actions, run the script then `git diff --exit-code public/fonts/`. If a developer adds a TTF without committing the matched WOFF, CI fails the PR — keeping source and artifacts in lockstep.
Two automation paths compared
Both produce the same spec-compliant WOFF 1.0. Choose by your constraints — dependency tolerance vs toolchain-free.
| Path | Dependency | Where it runs | Best for |
|---|---|---|---|
Node + pako | npm i pako (one dep) | CI runner / your machine, in-process | Self-contained scripts, no daemon to run |
| JAD local runner API | The paired runner (no font toolchain) | http://127.0.0.1:9789 on your machine | No-code-per-font, fonts never leave the box |
| Browser tool (manual) | None | Your browser tab | One-off conversions, not automation |
WOFF wrap byte layout (for the pako path)
What the encoder writes — mirrors the browser tool's sfntToWoff. All multi-byte fields big-endian.
| Section | Size | Contents |
|---|---|---|
| WOFF header | 44 bytes | wOFF signature, flavor, length, numTables, totalSfntSize, version 1.0, empty meta/priv offsets |
| Table directory | 20 bytes × numTables | Per table: tag, offset, compLength, origLength, origChecksum |
| Table data | padded to 4-byte boundaries | Each table zlib-deflated, or stored raw if deflate is not smaller |
Idempotency and reproducibility checklist
What makes the batch safe to run on every build.
| Property | How it's achieved | Why it matters |
|---|---|---|
| Deterministic output | Fixed algorithm, no timestamps in the WOFF | Same source → same bytes → CI cache hits |
| Skip-if-unchanged | mtime or source-hash comparison | Fast reruns; only changed weights reconvert |
| Empty metadata block | meta/priv offsets written as 0 | No embedded build date or vendor string to drift |
| CI lockstep | git diff --exit-code public/fonts/ | A new TTF without its WOFF fails the PR |
Cookbook
Drop-in scripts for the two automation paths, plus the CI guard. The pako recipe uses the same per-table-deflate logic as the browser tool.
Node: convert one font directory with pako
ExampleThe core loop. Reads each sfnt, deflates each table (keeping the smaller of compressed/original), and writes a WOFF 1.0. This is the same algorithm the browser tool runs.
import { readdir, readFile, writeFile } from "node:fs/promises";
import { deflate } from "pako";
function sfntToWoff(buf) {
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
const flavor = dv.getUint32(0), numTables = dv.getUint16(4);
const dir = [];
for (let i = 0; i < numTables; i++) {
const o = 12 + i * 16;
dir.push({
tag: dv.getUint32(o), csum: dv.getUint32(o + 4),
off: dv.getUint32(o + 8), len: dv.getUint32(o + 12),
});
}
const comp = dir.map(e => {
const raw = buf.subarray(e.off, e.off + e.len);
const z = deflate(raw);
return z.length < e.len
? { ...e, data: z, clen: z.length }
: { ...e, data: raw, clen: e.len };
});
// ...write 44-byte header + 20-byte dir entries + padded data (see byte-layout table)
// returns a WOFF buffer with magic 0x774F4646
}
for (const name of (await readdir("src/fonts")).filter(n => /\.(ttf|otf)$/i.test(n))) {
const out = sfntToWoff(await readFile(`src/fonts/${name}`));
await writeFile(`public/fonts/${name.replace(/\.\w+$/, ".woff")}`, out);
}JAD runner API: no code per font
ExampleIf the local runner is paired, POST each font to its ttf-to-woff endpoint and save the WOFF response. No npm dependency, fonts never leave your machine. Loop in your shell of choice.
for f in src/fonts/*.ttf src/fonts/*.otf; do
[ -e "$f" ] || continue
out="public/fonts/$(basename "${f%.*}").woff"
curl -sS -X POST http://127.0.0.1:9789/v1/tools/ttf-to-woff/run \
-F "file=@${f}" \
-o "$out"
echo "wrote $out"
doneSkip unchanged fonts for fast reruns
ExampleOnly reconvert when the source TTF is newer than the existing WOFF. Keeps the prebuild near-instant when nothing changed.
import { stat } from "node:fs/promises";
async function needsConvert(src, dst) {
try {
const [a, b] = await Promise.all([stat(src), stat(dst)]);
return a.mtimeMs > b.mtimeMs; // source newer → reconvert
} catch {
return true; // dst missing → convert
}
}
if (await needsConvert(srcPath, woffPath)) {
await writeFile(woffPath, sfntToWoff(await readFile(srcPath)));
}CI guard: fail the PR if WOFF is stale
ExampleRun the conversion in CI, then assert the working tree is clean. A committed TTF without its matching WOFF fails the check.
# .github/workflows/fonts.yml (excerpt)
- run: npm run fonts # converts src/fonts → public/fonts
- name: Ensure WOFF artifacts are committed
run: |
git diff --exit-code public/fonts/ \
|| (echo '::error::Run npm run fonts and commit the WOFF files' && exit 1)Produce WOFF and WOFF2 in one prebuild step
ExampleRun both conversions together so every weight ships both formats. They're independent and write to the same output directory.
// package.json
{
"scripts": {
"fonts": "node scripts/to-woff2.mjs && node scripts/to-woff.mjs",
"prebuild": "npm run fonts"
}
}
// result in public/fonts/:
// inter.woff2 inter.woff
// inter-bold.woff2 inter-bold.woff ...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.
A source file is a WOFF2, not a TTF
Larger outputIf your fonts/ directory already has WOFF2 files and you feed one in, the converter decodes it back to sfnt and re-wraps as WOFF — which is larger than the WOFF2 (zlib can't beat Brotli). For a build pipeline, always convert from the original TTF/OTF source, not from a previously-built web format, so each WOFF gets the smallest possible bytes.
A TrueType Collection (.ttc) is in the directory
Unsupported formatThe converter rejects ttcf with Unsupported font format: ttc — it wraps single faces only. Filter .ttc out of your glob, or split the collection into individual TTF/OTF faces (fonttools ttx or FontForge) before the batch step. Failing to filter will abort the run on that file.
Output isn't deterministic across machines
Check your pako versionThe WOFF wrap has no timestamps, so output should be byte-identical run to run. If you see diffs, pin the pako version in package-lock.json — a different zlib build can produce different (still valid) compressed streams. Pinning keeps CI caches and git diff guards stable.
A font exceeds the tier limit on the runner path
413-style rejectWhen using the runner API, the same per-tier file caps apply: 5 MB free, 50 MB pro, 1 GB developer. A font over the cap is rejected. Large CJK fonts can hit this — make sure the machine running the runner is on a tier that covers your largest face. The pure-pako Node path has no such cap (it's your own process).
Concurrency oversubscribes the CPU
Throttle itpako deflate is single-threaded pure JS, so Promise.all over hundreds of fonts at once oversubscribes cores and can thrash memory. Cap concurrency to your core count (8-16) with a simple pool. Conversion is CPU-bound, not I/O-bound, so unbounded parallelism gives no speedup and risks OOM on huge fonts.
Variable fonts in the batch
PreservedVariable fonts wrap to WOFF exactly like static fonts — the fvar/gvar tables ride through untouched and font-variation-settings still works after conversion in capable browsers. No special handling needed in the script. (If you want to freeze a variable font to a static instance first, that's the separate Variable Font Freezer tool.)
A table won't compress and is stored raw
By designFor each table the encoder keeps the smaller of the deflated stream or the original bytes, so tiny/dense tables are stored uncompressed with compLength === origLength. This is spec-correct and means the WOFF never inflates an individual table. Your size diffs will reflect this — it's normal, not a script bug.
CI guard fails on first run
Expected onceThe first time you add the git diff --exit-code guard, it fails because the WOFF artifacts don't exist yet. Run the conversion locally, commit the generated WOFF files, then the guard passes. Thereafter it only fails when a TTF changes without its WOFF being regenerated and committed.
Frequently asked questions
Should I run WOFF and WOFF2 conversion in parallel?
Yes — they're independent. Run both as one npm run fonts / prebuild step, both writing to public/fonts/. Modern browsers pick the WOFF2 via format() hints and IE11 falls back to the WOFF. See WOFF vs WOFF2 for the src ordering.
What's the simplest no-code path?
The JAD local runner. If it's paired, POST each font to http://127.0.0.1:9789/v1/tools/ttf-to-woff/run and save the response — no npm dependency, no font toolchain, and fonts never leave your machine. Loop it in a shell script over your fonts directory.
Is the WOFF output deterministic?
Yes. The wrap writes no timestamps, so the same input bytes produce the same output bytes — important for reproducible builds and CI cache hits. Pin your pako version in the lockfile to keep output stable across machines, since a different zlib build could emit a different (still valid) stream.
Can I parallelise across many fonts?
Yes, but cap concurrency to your core count (8-16). pako deflate is single-threaded pure JS and conversion is CPU-bound, so unbounded Promise.all oversubscribes cores without speeding anything up — and risks OOM on large CJK fonts. A small concurrency pool is the sweet spot.
What about variable fonts?
They wrap to WOFF the same way as static fonts — fvar/gvar and all other tables are preserved byte-for-byte, and font-variation-settings still works in capable browsers after conversion. No special script handling needed.
Does the runner path respect tier limits?
Yes — the same per-file caps apply (5 MB free, 50 MB pro, 1 GB developer). The pure-pako Node path runs in your own process and isn't gated by those limits. If you batch very large fonts via the runner, ensure that machine's tier covers your biggest face.
How do I keep source and artifacts in sync?
Add a CI guard: run the conversion, then git diff --exit-code public/fonts/. If a developer commits a new TTF without regenerating the WOFF, the diff is non-empty and the PR fails. The first run needs you to commit the initial artifacts.
Can I convert OTF (CFF) fonts in the batch too?
Yes. The same loop handles OTF — the flavor field copies OTTO, so the WOFF stays CFF-flavoured. Just include *.otf in your glob alongside *.ttf. Format is detected from magic bytes regardless of extension.
Should I subset before converting in the pipeline?
Often yes — subsetting to the glyphs you actually use shrinks the font far more than the wrapper does. Run a subsetter first (note JAD's in-browser Font Subsetter uses opentype.js and drops GSUB/GPOS; for layout-preserving subsetting use hb-subset/pyftsubset), then wrap the subset to WOFF/WOFF2.
Will the WOFF be larger than the source TTF?
No — from a raw TTF/OTF the WOFF is smaller (per-table zlib, 35-45% reduction typical). It's only larger when your source is itself a WOFF2, because re-wrapping inflates the Brotli stream into a weaker zlib one. Convert from the original source to avoid that.
Does this strip hinting or metadata?
No. The wrap is lossless — all sfnt tables (including hinting cvt /fpgm/prep and the name table) survive. The WOFF's own optional metadata block is left empty by design, but that's separate from the font's internal name/licence data, which is preserved. To deliberately strip hinting, use the Hinting Stripper first.
Can I do this fully offline / air-gapped?
Yes. The pako Node path needs only the pako package (vendored once) and no network. The runner path is also local (127.0.0.1). Neither sends fonts anywhere — ideal for licensed or confidential typefaces in an air-gapped build environment.
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.