How to batch apply latin filter across multi-weight font families
- Step 1Decide: browser-parity or kerning-preserving — If you want output byte-identical in behaviour to the [Latin Filter](/font-tools/latin-filter) (kerning dropped, TTF out), use the Node + opentype.js script. If your typography needs kerning/ligatures, skip opentype.js and use `pyftsubset --layout-features='*'` — it preserves `GSUB`/`GPOS`. Pick before you build.
- Step 2Install the toolchain — For browser-parity: `npm i opentype.js`. For kerning-preserving: `pip install fonttools brotli` (gives you `pyftsubset` with WOFF2 output) or build `hb-subset` from HarfBuzz. The two paths are independent; you can run both and compare.
- Step 3Point the script at your weights directory — Lay your source weights in one folder (`./src/fonts/*.{ttf,otf,woff,woff2}`). The Node script globs them, applies the Latin codepoint set `U+0020–U+00FF`, and writes `<stem>.latin.ttf` per file — mirroring the browser tool's filename convention.
- Step 4Run the subset across the family — One command processes every weight. The opentype.js script keeps glyph 0 (`.notdef`) and any glyph whose `unicode`/`unicodes` falls in the Latin range, then writes a fresh TTF — exactly what the in-browser tool does. The pyftsubset loop does the same charset with `--unicodes=U+0020-00FF`.
- Step 5Compress each subset to WOFF2 — Both engines (or a post-step) should emit WOFF2 for the web. pyftsubset can write WOFF2 directly with `--flavor=woff2`; the opentype.js path writes TTF, so add a `ttf-to-woff2` step (the [ttf-to-woff2 tool](/font-tools/ttf-to-woff2) or the wasm encoder it wraps).
- Step 6Generate the @font-face CSS and verify — Emit one `@font-face` per weight with `unicode-range: U+0000-00FF`. Spot-check a few weights with [character-coverage-map](/font-tools/character-coverage-map) and, if you used opentype.js, confirm kerning loss is acceptable with [kerning-pair-auditor](/font-tools/kerning-pair-auditor).
Engine choice: opentype.js vs harfbuzz
The fork that determines your script. The browser Latin Filter is the opentype.js column — match it for parity, or pick harfbuzz when layout tables matter.
| Property | opentype.js (browser-parity) | pyftsubset / hb-subset (harfbuzz) |
|---|---|---|
| Matches in-browser Latin Filter | Yes — same library, same result | No — preserves more |
| Kerning / ligatures (GPOS/GSUB) | Dropped | Preserved (--layout-features='*') |
| Output format | TTF (then compress) | TTF / WOFF / WOFF2 (--flavor=) |
| CFF / OTF input | May fail in writer | Handled natively |
| Dependency | npm i opentype.js (pure JS) | Python + fontTools, or C++ HarfBuzz |
The Latin charset, expressed per tool
All three express the identical 191-codepoint set the JAD Latin preset uses. The C0/C1 control gaps are intentional.
| Tool | How the Latin range is specified |
|---|---|
| JAD Latin Filter (browser) | Fixed preset [[0x20,0x7e],[0xA0,0xFF]] — no input |
| Node + opentype.js | Build a Set of codepoints 0x20–0x7E and 0xA0–0xFF; keep glyphs whose unicode/unicodes are in it |
| pyftsubset | --unicodes=U+0020-007E,U+00A0-00FF |
| hb-subset | --unicodes=20-7E,A0-FF |
Pipeline composition
A full family build, from source weights to deployable CSS. Each stage is scriptable.
| Stage | Input | Output | Tool |
|---|---|---|---|
| 1. Subset | *.ttf (all weights) | *.latin.ttf | Node opentype.js OR pyftsubset |
| 2. Compress | *.latin.ttf | *.latin.woff2 | ttf-to-woff2 / pyftsubset --flavor=woff2 |
| 3. CSS | weight list | @font-face rules | font-face-generator |
| 4. Verify | *.latin.woff2 | coverage / kerning report | character-coverage-map, kerning-pair-auditor |
Cookbook
Real scripts. The opentype.js one reproduces the browser tool's exact behaviour; the pyftsubset one is the kerning-preserving alternative. All run locally — nothing is uploaded.
Node + opentype.js — browser-parity, whole family
ExampleGlobs every weight, keeps the Latin codepoints plus .notdef, rebuilds a fresh TTF per weight. Output behaviour matches the in-browser Latin Filter exactly: TTF out, kerning/layout dropped.
// subset-latin.mjs — node subset-latin.mjs ./src/fonts
import fs from "node:fs";
import path from "node:path";
import opentype from "opentype.js";
const keep = new Set();
for (let c = 0x20; c <= 0x7e; c++) keep.add(c);
for (let c = 0xa0; c <= 0xff; c++) keep.add(c);
const dir = process.argv[2] ?? "./src/fonts";
for (const f of fs.readdirSync(dir).filter(n => /\.ttf$/i.test(n))) {
const font = opentype.loadSync(path.join(dir, f));
const glyphs = [font.glyphs.get(0)]; // .notdef
for (let i = 1; i < font.glyphs.length; i++) {
const g = font.glyphs.get(i);
const us = g.unicodes?.length ? g.unicodes : [g.unicode];
if (us.some(u => u != null && keep.has(u))) glyphs.push(g);
}
const out = new opentype.Font({
familyName: font.getEnglishName("fontFamily") || f,
styleName: font.getEnglishName("fontSubfamily") || "Regular",
unitsPerEm: font.unitsPerEm,
ascender: font.ascender,
descender: font.descender,
glyphs,
});
const dst = path.join(dir, f.replace(/\.ttf$/i, ".latin.ttf"));
fs.writeFileSync(dst, Buffer.from(out.toArrayBuffer()));
console.log(`${f} -> ${path.basename(dst)} (${glyphs.length} glyphs)`);
}pyftsubset — kerning-preserving, whole family
ExampleSame Latin charset, but harfbuzz keeps GSUB/GPOS so kerning and ligatures survive — and writes WOFF2 directly. Use this when the in-browser tool's dropped layout tables aren't acceptable.
#!/usr/bin/env bash
for f in ./src/fonts/*.ttf; do
pyftsubset "$f" \
--unicodes=U+0020-007E,U+00A0-00FF \
--layout-features='*' \
--flavor=woff2 \
--output-file="${f%.ttf}.latin.woff2"
echo "subset $f -> ${f%.ttf}.latin.woff2"
done
# Layout tables preserved; output is web-ready WOFF2.Compress the opentype.js TTFs to WOFF2
ExampleThe opentype.js path emits TTF, so add a WOFF2 step. This wraps the same wasm encoder the ttf-to-woff2 tool uses.
// after subset-latin.mjs
import { compress } from "wawoff2"; // wasm WOFF2 encoder
import fs from "node:fs";
for (const f of fs.readdirSync("./src/fonts")
.filter(n => /\.latin\.ttf$/i.test(n))) {
const ttf = fs.readFileSync(`./src/fonts/${f}`);
const woff2 = await compress(new Uint8Array(ttf));
fs.writeFileSync(`./src/fonts/${f.replace(/\.ttf$/, ".woff2")}`, woff2);
}Emit the @font-face CSS
ExampleOne rule per weight, all sharing the family name, with the Latin unicode-range so the browser skips the file on non-Latin pages.
const weights = { 400: "Regular", 500: "Medium", 700: "Bold" };
let css = "";
for (const [w, name] of Object.entries(weights)) {
css += `@font-face{font-family:"Brand";font-weight:${w};` +
`font-display:swap;` +
`src:url(/f/Brand-${name}.latin.woff2) format("woff2");` +
`unicode-range:U+0000-00FF;}\n`;
}
fs.writeFileSync("./dist/fonts.css", css);Run via the @jadapps/runner HTTP API
ExamplePrefer one HTTP call per font over installing toolchains? Pair the runner once; it executes the latin-filter job on your machine. The schema is option-free (the Latin preset is fixed).
# 1. Inspect the (option-free) schema curl -H "Authorization: Bearer $JAD_API_KEY" \ https://jadapps.example/api/v1/tools/latin-filter # 2. Dispatch to your paired local runner (font stays local) # POST the file to the runner endpoint per the API docs: # 127.0.0.1:9789/v1/tools/latin-filter/run # The runner uses a layout-preserving engine where available; # for byte-for-byte browser parity, use the opentype.js script.
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 writer fails on a CFF/OTF weight
Error — writer limitationIf a weight has CFF (PostScript) outlines — common in .otf families — font.toArrayBuffer() can throw, exactly like the in-browser tool's message about the opentype.js writer. In a batch, wrap each file in try/catch and either skip-and-log or route that weight to pyftsubset, which handles CFF natively. Mixed TTF/OTF families are the usual trigger.
A weight has no Latin glyphs
Error — empty subsetIf a source file covers a non-Latin script only, the kept-glyph count is zero. The browser tool throws 'Subset would be empty'; your script should detect glyphs.length === 1 (just .notdef) and skip with a warning rather than write a degenerate font. Confirm coverage first with character-coverage-map.
Kerning silently lost across the whole family
By design (opentype.js)Every weight built with the opentype.js script loses GPOS/GSUB. If your design system relies on kerning at display sizes, the whole family ships un-kerned and nobody notices until a headline looks off. If that's unacceptable, the entire build should use pyftsubset --layout-features='*' instead. Decide per design system, not per file.
Variable-font weights collapse to default
By designIf your 'family' is actually one variable font, the opentype.js rebuild flattens it to the default instance — you won't get distinct weights. Freeze each named instance to a static font with variable-font-freezer first, then run the batch over the frozen statics. pyftsubset can keep variations with --instancer, but that's a different workflow.
WOFF2 input handled, but output is still TTF
Expectedopentype.loadSync doesn't read WOFF2; decompress to TTF first (wawoff2) or feed TTF/OTF sources. Either way the opentype.js path writes TTF — add the WOFF2 compression step. This mirrors the browser tool, which accepts WOFF2 input but always returns TTF.
Family/style names lost or duplicated
Watch forThe script copies fontFamily/fontSubfamily from each source's name table. If a source font has a non-standard or missing name record, the rebuilt font may fall back to the filename, breaking your @font-face matching. Verify with font-metadata-extractor and override familyName/styleName explicitly if needed.
Hinting dropped — fine on modern OSes
By designLike the browser tool, the opentype.js rebuild doesn't carry fpgm/prep/cvt, so TrueType hinting is gone. Modern macOS/iOS/Windows 10+ render outline-only and look identical; only legacy Windows GDI users see a difference. pyftsubset preserves hinting by default (--no-hinting to drop it) if you need it.
Non-deterministic glyph order between runs
Watch forIf you parallelise the batch and rely on glyph IDs being stable, note that subset glyph ordering follows source iteration order — deterministic per file but not comparable across different source fonts. For reproducible CI artifacts, sort inputs and run single-threaded, or hash outputs to detect real changes.
Output larger than expected per weight
ExpectedEach .latin.ttf is uncompressed with table padding, so a 191-glyph subset can still be 30–60 KB. Judge the family's total weight on the compressed WOFF2 artifacts, not the intermediate TTFs. The pyftsubset path skips this by writing WOFF2 directly.
Mixed input formats in one folder
Handle in globA real family folder may mix .ttf, .otf, .woff, and .woff2. The opentype.js path needs TTF/OTF (and OTF may fail the writer); WOFF/WOFF2 must be decompressed first. Normalise inputs to TTF up front, or branch the glob so WOFF/WOFF2 go through a decompression step before subsetting.
Frequently asked questions
Will the Node script produce the same output as the browser tool?
If it uses opentype.js with the same Latin codepoint set and rebuilds the font the same way (keep .notdef + matching glyphs, copy unitsPerEm/ascender/descender, write TTF), yes — it's browser-parity: same U+0020–U+00FF range, TTF output, and dropped GPOS/GSUB. The cookbook script above mirrors the tool's logic deliberately.
How do I keep kerning when batching?
Don't use opentype.js — it drops layout tables. Use pyftsubset --unicodes=U+0020-007E,U+00A0-00FF --layout-features='*' --flavor=woff2, or hb-subset. Both are harfbuzz-based and preserve GSUB/GPOS, so kerning and ligatures survive. The trade-off is a Python (fontTools) or C++ (HarfBuzz) dependency instead of pure JS.
Can I batch through the browser tool itself?
No — the in-browser Latin Filter processes one file per run with no multi-file upload. For automation, either run the Node/opentype.js script locally, use pyftsubset, or call GET /api/v1/tools/latin-filter and dispatch jobs to a paired @jadapps/runner one font at a time. The runner executes locally so font bytes never leave your machine.
What charset exactly should I pass to match the preset?
U+0020-007E and U+00A0-00FF — 191 codepoints. Don't include the C0 controls (U+0000–U+001F), DEL (U+007F), or the C1 block (U+0080–U+009F); the JAD preset omits them and they aren't glyphs anyway. NBSP is U+00A0 and IS included. For pyftsubset: --unicodes=U+0020-007E,U+00A0-00FF.
Why does opentype.loadSync fail on my WOFF2 files?
opentype.js doesn't parse WOFF2 directly. Decompress to TTF first (the wawoff2 wasm package, same one the browser tool uses) or keep TTF/OTF sources in your build. The browser tool handles WOFF2 input by decompressing internally before subsetting — replicate that step in your script.
How do I handle OTF weights that crash the writer?
OTF (CFF outlines) can throw in opentype.js's toArrayBuffer(). Wrap each file in try/catch; on failure, route it to pyftsubset (which handles CFF natively) or convert the OTF to TrueType-outline TTF first in FontForge. A mixed TTF/OTF family is the common cause of a half-failing batch.
Does pyftsubset output WOFF2 directly?
Yes, with --flavor=woff2 (requires the brotli Python package). That collapses subset + compress into one step, unlike the opentype.js path which writes TTF and needs a separate WOFF2 compression pass. For a clean CI artifact, pyftsubset → WOFF2 in one command is the simplest.
How do I generate the @font-face CSS to go with the subsets?
Loop your weight map and emit one rule per weight sharing the family name, each with unicode-range: U+0000-00FF. The cookbook has a script, or use font-face-generator interactively. The unicode-range lets the browser skip the file on pages with no Latin text in a layered i18n setup.
Is the output reproducible for CI caching?
Per-file, yes — the same source bytes and the same charset produce the same subset, so you can cache on a content hash. Across different source fonts, glyph IDs aren't comparable. For deterministic artifacts, run single-threaded or sort inputs, and hash the output WOFF2 to decide whether a rebuild is needed.
What about variable fonts in the batch?
The opentype.js path flattens a variable font to its default instance, so you'd lose the other weights. Freeze each named instance to a static font with variable-font-freezer first, then batch over the statics. If you must keep variation, pyftsubset's --instancer is a separate, more advanced workflow.
How do I verify the batch didn't break anything?
Spot-check coverage with character-coverage-map, confirm names with font-metadata-extractor, and if you used opentype.js, check kerning loss is acceptable with kerning-pair-auditor. Render a per-weight specimen string to catch missing glyphs before you ship.
Does the runner API need me to upload my fonts?
No. JAD's API/MCP layer is upload-free — GET /api/v1/tools/latin-filter returns the schema, but the actual job runs on a @jadapps/runner paired to your machine, so font content never reaches JAD's servers. Hitting the /run endpoint with content returns a 400 with pairing instructions by design.
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.