How to automate emoji removal in your font build pipeline
- Step 1Decide whether you need layout preservation — If the font is a body/text face with real kerning and ligatures, use hb-subset or `pyftsubset --layout-features='*'`. If it is a display/heading face where kerning is hand-tuned per word anyway, the opentype.js path (same as JAD's browser tool) is fine and has zero Python dependency.
- Step 2Walk the fonts directory — `fs.readdir(input)` over your source fonts. For each, parse with opentype.js, build the keep-set (every codepoint NOT in `U+1F000–U+1FFFF`, `U+2600–U+27BF`, `U+E000–U+F8FF`, `U+1F300–U+1FAFF`), and emit a fresh subset font. This is exactly what the hosted tool does.
- Step 3Or shell out to hb-subset for kerning-safe runs — Compute the keep-set as a Unicode-range expression that excludes the emoji blocks, then pass it to `hb-subset --unicodes-file=keep.txt --layout-features='*'`. harfbuzz preserves `GSUB`/`GPOS`, so kerning and ligatures survive — the one thing the opentype.js path cannot do.
- Step 4Compress to WOFF2 as a later stage — Pipe each emoji-stripped TTF through `wawoff2` (or `woff2_compress`). Final artefacts land in `/public/fonts/`. Compression is a separate stage from emoji removal — keep them decoupled so you can cache each independently.
- Step 5Make it idempotent and fail-loud — Skip fonts whose cmap has no codepoints in the ranges (no work to do). Wrap the writer in try/catch: opentype.js throws on some CFF/PostScript-outline fonts, and you want CI to fail with a clear message rather than emit a corrupt file.
- Step 6Validate the OS fallback with a screenshot test — Take a Playwright screenshot of a page with emoji content and diff it against a baseline that uses OS emoji rendering. This catches the regression where the font-fallback chain breaks and emoji render as tofu instead of falling through to Segoe/Apple/Noto.
Engine choice for CI emoji stripping
The decisive axis is whether layout (kerning, ligatures, GSUB/GPOS) must survive. JAD's hosted browser tool is the opentype.js row.
| Engine | Keeps kerning / GSUB / GPOS? | Dependency | Use when |
|---|---|---|---|
| opentype.js (JAD browser tool) | No — outlines only | Pure JS / Node | Display faces, icon-free fonts, no Python in CI |
harfbuzz hb-subset | Yes with --layout-features='*' | harfbuzz binary | Body fonts, kerning-critical text, ligatures |
pyftsubset (fonttools) | Yes with --layout-features='*' | Python + fonttools | Already in a Python build; need fine control |
| JAD runner endpoint | Matches the browser tool (opentype.js) | Paired @jadapps runner | You want hosted-identical output, offloaded locally |
Removal ranges to encode in the script
Mirror these exactly to match the hosted JAD Emoji Remover. Note U+1F300–U+1FAFF is already inside U+1F000–U+1FFFF.
| Range | Block(s) | Notes for automation |
|---|---|---|
U+1F000–U+1FFFF | Mahjong/Domino/Cards, Emoticons, Transport, Supplemental Pictographs | Supplementary plane — make sure your codepoint walk reads codePointAt, not charCodeAt |
U+2600–U+27BF | Misc Symbols + Dingbats | Removes text-mode symbols (✓ ❤ ☺) too — whitelist them if you depend on them |
U+E000–U+F8FF | Private Use Area | Will delete icon-font glyphs — exclude this range if the font is an icon set |
U+1F300–U+1FAFF | Misc Symbols & Pictographs → Extended-A | Redundant with the first row but present in the reference impl |
Cookbook
Drop-in CI snippets. The opentype.js script matches the hosted tool's behaviour exactly; the hb-subset path is for kerning-critical fonts.
Pure-JS strip (matches the hosted tool)
ExampleSame logic as JAD's browser Emoji Remover: walk every glyph, keep codepoints outside the four ranges, rebuild a TTF. Drops kerning/layout — use for display faces.
import fs from "node:fs";
import opentype from "opentype.js";
const RANGES = [[0x1f000,0x1ffff],[0x2600,0x27bf],[0xe000,0xf8ff],[0x1f300,0x1faff]];
const isEmoji = cp => RANGES.some(([lo,hi]) => cp >= lo && cp <= hi);
for (const name of fs.readdirSync("src/fonts")) {
const font = opentype.loadSync(`src/fonts/${name}`);
const keep = [font.glyphs.get(0)]; // .notdef
for (let i = 1; i < font.glyphs.length; i++) {
const g = font.glyphs.get(i);
if (g.unicode != null && !isEmoji(g.unicode)) keep.push(g);
}
const out = new opentype.Font({
familyName: font.getEnglishName("fontFamily"),
styleName: font.getEnglishName("fontSubfamily") || "Regular",
unitsPerEm: font.unitsPerEm,
ascender: font.ascender, descender: font.descender,
glyphs: keep,
});
fs.writeFileSync(`dist/fonts/${name.replace(/\.\w+$/, "")}.no-emoji.ttf",
Buffer.from(out.toArrayBuffer()));
}
// NOTE: GPOS/GSUB are NOT carried -> kerning/ligatures gone.Kerning-safe strip with hb-subset
ExampleWhen the font needs kerning, build a keep-list of Unicode ranges that excludes the emoji blocks and let harfbuzz preserve layout features.
# keep everything except the emoji ranges, keep all layout features hb-subset src/fonts/Inter.ttf \ --unicodes='0-25FF,2800-DFFF,F900-1F2FF,20000-2FFFF' \ --layout-features='*' \ --output-file=dist/fonts/Inter.no-emoji.ttf # kerning + ligatures survive; emoji/dingbat/PUA glyphs are gone woff2_compress dist/fonts/Inter.no-emoji.ttf
Offload to the paired JAD runner
ExampleThe runner runs the tool locally so the font never leaves your machine. emoji-remover takes no options, so the inputs object is empty.
curl -sS -X POST http://127.0.0.1:9789/v1/tools/emoji-remover/run \
-F 'file=@src/fonts/Brand-Display.ttf' \
-F 'inputs={}' \
-o dist/fonts/Brand-Display.no-emoji.ttf
# Output is TTF (the tool always emits sfnt); compress afterwards:
curl -sS -X POST http://127.0.0.1:9789/v1/tools/ttf-to-woff2/run \
-F 'file=@dist/fonts/Brand-Display.no-emoji.ttf' -F 'inputs={}' \
-o dist/fonts/Brand-Display.no-emoji.woff2Idempotent skip + compress stage
ExampleSkip fonts with no emoji codepoints (nothing to gain) and run compression as a decoupled stage so each cache invalidates independently.
import opentype from "opentype.js";
const RANGES = [[0x1f000,0x1ffff],[0x2600,0x27bf],[0xe000,0xf8ff]];
const hasEmoji = f => {
for (let i=0;i<f.glyphs.length;i++){
const cp=f.glyphs.get(i).unicode;
if (cp!=null && RANGES.some(([l,h])=>cp>=l&&cp<=h)) return true;
} return false;
};
const font = opentype.loadSync(input);
if (!hasEmoji(font)) { console.log(`skip ${input}: no emoji block`); process.exit(0); }
// ...strip + write... then in a SEPARATE step: woff2_compress out.ttfFail-loud on a writer error
Exampleopentype.js throws on some CFF/PostScript-outline fonts. Wrap the write so CI fails with a useful message instead of emitting a corrupt TTF.
try {
fs.writeFileSync(outPath, Buffer.from(newFont.toArrayBuffer()));
} catch (err) {
console.error(
`Emoji strip failed for ${name}: ${err.message}\n` +
`This usually means CFF/PostScript outlines. Convert to TTF first ` +
`(fontforge / hb-subset) and re-run.`);
process.exit(1); // break the build, do not ship a broken font
}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 path silently loses kerning in CI
By designThe pure-JS script (and the hosted JAD tool) drop GPOS/GSUB. In a CI pipeline this is silent — the build passes, the font ships, and body text reflows because kerning is gone. Add an assertion that checks the output still has a GPOS table if your font needs it, or switch that font to the hb-subset path. This is the single most common automation mistake.
PUA icon glyphs deleted across the whole build
RemovedU+E000–U+F8FF is in the default ranges. If your monorepo ships both a text font and an icon font through the same step, the icon font is gutted. Exclude the PUA range for icon fonts, or route them away from the emoji step entirely. Run Character Coverage Map in a pre-flight check to flag PUA-heavy fonts.
Supplementary-plane codepoints missed with charCodeAt
Bug riskEmoji live above U+FFFF. If your script iterates strings with charCodeAt or assumes BMP, you'll read surrogate halves and miss the emoji. Always walk the font's glyphs by index and read glyph.unicode (the numeric codepoint), exactly as the reference implementation does — don't reconstruct from strings.
Output TTF is bigger than the source WOFF2
ExpectedThe strip step emits uncompressed sfnt. If your source artefact was WOFF2, the intermediate TTF is much larger — that's normal. Always run compression as a downstream stage and compare final WOFF2 sizes, not the intermediate. Decouple the stages so you don't accidentally ship the TTF.
CFF / PostScript fonts throw in the writer
Erroropentype.js's toArrayBuffer() can fail on CFF outlines. Without a try/catch your CI job dies with an opaque stack trace. Wrap it, print a clear message, and either pre-convert to TTF or route CFF fonts through hb-subset, which handles them natively.
Re-running mutates an already-stripped font
PreservedRunning the strip on an already-emoji-free font is harmless — the keep-set is the whole font — but the rebuild still drops layout each pass and re-emits glyf/loca, so size can drift on repeated runs. Make the step idempotent by skipping fonts whose cmap has no codepoints in the ranges.
Tier file limit blocks large fonts on the runner
RejectedIf you route through the hosted tool or a free-tier runner, the 5 MB cap applies (Pro 50 MB, Developer 1 GB). A multi-megabyte colour-emoji font won't process on free. For pure local CI, the standalone opentype.js / hb-subset scripts have no such cap — they run in your own Node/shell.
Screenshot test flags tofu after a bad fallback
CaughtIf the deployed CSS font-stack doesn't end in a generic family, a browser with no OS emoji could render tofu (□) where emoji used to be. The Playwright diff catches this. Fix is in the stack, not the font: always end the family list with a generic so the OS emoji font is reachable.
Frequently asked questions
Should I run this on system fonts?
No — system fonts aren't part of your bundle and you don't ship them. The pipeline only applies to custom web fonts you serve via @font-face. Stripping emoji from a font the user already has installed accomplishes nothing.
Will the script keep kerning?
Only if you use harfbuzz hb-subset or pyftsubset --layout-features='*'. The opentype.js path (and the hosted JAD tool) drop GPOS/GSUB, so kerning and ligatures are gone. Pick the engine by whether the font needs layout — see the engine table above.
What about colour emoji in my brand identity?
Don't run the strip on a branded-emoji font. Skip it in the pipeline, or use a selective keep-list (hb-subset --unicodes or the Character Whitelist Builder) that retains just your brand emoji and drops the rest.
Can I exclude specific ranges?
In your own script, yes — edit the RANGES array (e.g. drop [0xe000,0xf8ff] to spare icon-font PUA glyphs). The hosted JAD tool has no options, so its ranges are fixed; for custom range control either run your own script or use the Character Whitelist Builder / Font Subsetter.
How do I match the hosted tool exactly?
Use the opentype.js snippet above (identical range set and keep logic) or POST to http://127.0.0.1:9789/v1/tools/emoji-remover/run on a paired runner with inputs={}. Both produce a .no-emoji.ttf built from the non-emoji glyphs, with layout dropped.
Does the runner upload my fonts anywhere?
No. The @jadapps runner executes the tool on your own machine at 127.0.0.1:9789. Fonts stay local. This is the recommended path for proprietary brand fonts in CI.
What's the right pipeline order?
Source font → emoji strip → (optional) subset to your language coverage → WOFF2 compression. Keep each as a discrete, cached stage. Emoji removal and compression must be separate steps because the strip emits uncompressed TTF.
How do I make it idempotent?
Before stripping, check whether any cmap codepoint falls in the ranges; if none do, skip the font (no benefit, and re-emitting just churns size). The hosted tool always rebuilds, so for true idempotency add the skip check in your own script.
Why does CI sometimes emit a corrupt font?
Almost always a CFF/PostScript-outline font that opentype.js's writer couldn't rebuild — and an uncaught exception left a partial file. Wrap toArrayBuffer() in try/catch and process.exit(1) on failure, or route OTFs through hb-subset which handles CFF natively.
Can I validate the result automatically?
Yes — two checks. (1) Assert the output cmap has no codepoints in the emoji ranges (re-run your hasEmoji predicate on the output). (2) A Playwright screenshot diff on an emoji-bearing page to confirm the OS fallback renders. Together they catch both over- and under-removal.
Does it handle WOFF2 input in CI?
The hosted tool and runner decompress WOFF2 to sfnt automatically. In a self-rolled opentype.js script you must decompress first (e.g. wawoff2.decompress) before opentype.parse. hb-subset reads WOFF2 directly in recent builds.
What if the font has no emoji at all?
The strip is a near no-op — every glyph is kept. But the opentype.js rebuild still drops layout, so don't run it blindly. Gate the step on the hasEmoji check so fonts without an emoji block pass through untouched.
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.