How to strip colour tables in your build pipeline
- Step 1Choose runner API vs in-process surgery — For exact parity with the web tool, run the JAD runner locally and POST to `http://127.0.0.1:9789/v1/tools/colour-table-remover/run`. For a zero-service build, replicate the surgery: read the SFNT directory, drop the eight tags, rebuild. Both produce an uncompressed TTF.
- Step 2Pair the runner (API path) — `GET /api/v1/tools/colour-table-remover` returns the (empty) option list — there is nothing to pass. Once @jadapps/runner is paired, `POST` the font as multipart form-data and write the returned `.monochrome.ttf` to your build output.
- Step 3Walk the fonts directory (Node path) — `fs.readdir(input)`. For each `.ttf`/`.otf`/`.woff`/`.woff2`, decompress WOFF/WOFF2 to SFNT if needed, then remove the tables `['COLR','CPAL','sbix','SVG ','CBDT','CBLC','EBDT','EBLC']` and rebuild the directory (entries re-sorted by tag, offsets recomputed).
- Step 4Re-compress to WOFF2 — The monochrome SFNT is uncompressed. Run it through `woff2_compress`/`wawoff2` (the same engine behind [TTF to WOFF2](/font-tools/ttf-to-woff2)) so the artifact you ship is actually smaller than the colour source.
- Step 5Verify the output in CI — Parse the result (opentype.js or fontkit) and assert none of the eight tags survived and the font still loads. Optionally diff table counts in vs out. Fail the build if a colour table leaked through.
- Step 6Make the step idempotent and cached — Skip fonts that already lack colour tables (the transform is a no-op there anyway) and key a build cache on the source font hash so unchanged fonts aren't reprocessed every run.
CI integration approaches
Both paths produce the same monochrome SFNT. Pick the runner for exact parity, the Node path to avoid running a service.
| Approach | Dependency | Network | Best for |
|---|---|---|---|
| Runner HTTP API | @jadapps/runner running locally | localhost only — bytes never leave the box | Exact parity with the web tool |
| In-process Node surgery | None (plain JS DataView) | None | Hermetic builds, no service to manage |
| Post-step: WOFF2 compress | wawoff2 / woff2_compress | None | Shippable web artifact |
| Post-step: verify | opentype.js / fontkit | None | Asserting the strip actually worked |
What to assert in the verify step
The transform has predictable, checkable post-conditions.
| Assertion | Why | Failure means |
|---|---|---|
No COLR/CPAL/sbix/SVG /CBDT/CBLC in directory | Confirms colour tables removed | Strip didn't run or input had unexpected tags |
| Font parses (opentype.js) | Confirms a valid SFNT was produced | Directory rebuild corrupted offsets |
glyf/CFF present | Outlines survived | You stripped more than intended |
| Output size sane vs input | Catches WOFF2-in → TTF-out inflation | Forgot the WOFF2 re-compression step |
Cookbook
Copy-paste-ready snippets for the two integration paths plus the compress/verify post-steps.
Runner HTTP API — strip one font
ExampleWith @jadapps/runner paired and running, POST the font and write the monochrome TTF. No options to send — the tool takes none.
curl -sS -X POST http://127.0.0.1:9789/v1/tools/colour-table-remover/run \
-F 'files=@src/fonts/BrandColour.otf' \
-o build/fonts/BrandColour.monochrome.ttf
# discover the (empty) option list:
curl -sS http://127.0.0.1:9789/v1/tools/colour-table-remover
# -> { "options": [] }Node — replicate the eight-table surgery
ExampleThe same removal set the web tool uses, applied with a plain DataView walk. Decompress WOFF/WOFF2 to SFNT first (e.g. with wawoff2 / pako).
const REMOVE = ['COLR','CPAL','sbix','SVG ','CBDT','CBLC','EBDT','EBLC'];
// sfnt: ArrayBuffer of a TTF/OTF (decompress WOFF/WOFF2 beforehand)
function stripColour(sfnt) {
const dv = new DataView(sfnt);
const numTables = dv.getUint16(4);
const drop = new Set(REMOVE);
// read 16-byte directory entries, keep those whose 4-char tag isn't in drop,
// re-sort kept entries by tag, recompute offsets, write a fresh directory.
// (head checkSumAdjustment is NOT recomputed — renderers ignore it.)
return rebuild(sfnt, dv, numTables, drop);
}GitHub Actions — strip, compress, gate on size
ExampleProduce a monochrome WOFF2 from a colour source and fail the build if it exceeds a budget.
- name: Monochrome fallback
run: |
node scripts/strip-colour.mjs src/fonts -o build/mono
for f in build/mono/*.ttf; do woff2_compress "$f"; done
SIZE=$(stat -c%s build/mono/BrandColour.monochrome.woff2)
test "$SIZE" -lt 51200 || { echo "mono font too big: $SIZE"; exit 1; }Verify the strip actually happened
ExampleParse the output and assert the colour tables are gone and the font still loads.
import opentype from 'opentype.js';
const font = opentype.loadSync('build/mono/BrandColour.monochrome.ttf');
const tags = Object.keys(font.tables || {});
for (const bad of ['colr','cpal','sbix','svg','cbdt','cblc'])
if (tags.includes(bad)) throw new Error(`colour table leaked: ${bad}`);
console.log('ok: monochrome, glyphs =', font.glyphs.length);Idempotent folder pass
ExampleRun across a mixed folder safely — non-colour fonts are returned unchanged by the tool, so the step is a no-op for them.
for src/fonts/*.{ttf,otf,woff,woff2}:
out = stripColour(src) # no-op if no colour tables present
if out is byte-identical to src: # nothing was chromatic
skip # avoids needless WOFF2 recompress
else:
write out.monochrome.ttf; woff2_compressEdge 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.
Forgetting the WOFF2 re-compression step
Bloated artifactThe tool emits uncompressed TTF. If your source was WOFF2, the .monochrome.ttf can be larger than the input despite having fewer tables. Always add a woff2_compress step; the monochrome WOFF2 is where the win shows. Add a size assertion to catch a missing compress step.
Running on a `.ttc` collection in CI
Unsupported formatThe runner/tool rejects ttcf collections with Unsupported font format. Split the collection to individual faces in a prior build step before stripping. Don't assume .ttf extension means single-face — sniff the magic bytes.
Re-running the step every build
Wasted CI timeStripping plus WOFF2 compression on a large emoji font is not free. Key a cache on the source font hash and skip unchanged inputs. The strip itself is idempotent, but the compression cost recurs if you don't cache.
Strict validator flags the head checksum
Validator warningThe directory rebuild changes offsets but does not recompute the head table's checkSumAdjustment. Renderers ignore it, but a strict validator in CI may warn. Run a checksum-fixing pass after stripping if your validation gate requires it.
Output font is monochrome but glyphs look empty
Minimal outlinesSome colour fonts (notably Apple Color Emoji) carry deliberately minimal outline glyphs because they expect to always render in colour. After stripping you'll see sparse shapes. This is the font's fault, not the strip's — verify outline quality with Glyph Inspector before relying on the fallback.
Free-tier size limit hit in a hosted runner
413 tier limitIf you route through a tier-limited path, free caps at 5 MB and Pro at 50 MB per job. Full emoji fonts exceed both. Subset first with Font Subsetter, or run the local in-process surgery which is not tier-gated.
WOFF input with a malformed table directory
Parse errorWOFF inputs are zlib-inflated per table and rebuilt into an SFNT. A corrupt WOFF (bad compLength, truncated stream) throws during inflation. Re-export the source font cleanly; the tool doesn't repair malformed inputs.
Expecting per-table control in the API
Not supportedGET /api/v1/tools/colour-table-remover returns an empty option list — you cannot ask it to keep COLR but drop sbix. The transform is all-or-nothing. If you need selective removal, do the directory surgery yourself with a custom keep/drop set.
Stripping a font that had no colour tables
By designThe transform returns the font unchanged when none of the eight tags are present. In a folder pass, detect the byte-identical result and skip the (otherwise wasteful) WOFF2 recompress for those fonts.
Frequently asked questions
Can I run colour-table removal headlessly in CI?
Yes. Pair @jadapps/runner and POST http://127.0.0.1:9789/v1/tools/colour-table-remover/run with the font as multipart form-data — font bytes stay on your build machine. GET /api/v1/tools/colour-table-remover returns the (empty) option list. Or replicate the eight-table directory surgery in a Node step for a hermetic build.
Does it need Python or fonttools?
No. The web tool and runner do the work in JavaScript via raw SFNT directory manipulation. If you replicate it in your own Node step, it's pure DataView arithmetic — no native binary, no Python.
Is the operation idempotent?
Yes. A font with none of the eight tags is returned unchanged (a copy of the original SFNT). Running the step twice, or across a mixed folder, won't corrupt non-colour fonts — the second run is a no-op.
What does it output, and do I need to compress it?
It outputs an uncompressed <stem>.monochrome.ttf. Yes, you should compress it: add a woff2_compress/wawoff2 step (the engine behind TTF to WOFF2). Without it, a WOFF2-sourced font can end up larger than the input.
Can I keep some colour tables and drop others?
Not through this tool — it has no options and removes all eight tags. For selective removal, write your own directory-surgery step with a custom keep/drop set.
How do I verify the strip worked in CI?
Parse the output with opentype.js or fontkit and assert that COLR, CPAL, sbix, SVG , CBDT, CBLC are absent and that the font still loads with its glyphs intact. Fail the build if any colour tag leaked through.
Does it work on both TTF and OTF sources?
Yes. Colour tables are independent of outline format, so TTF (glyf) and OTF (CFF ) chromatic fonts are handled the same way. WOFF and WOFF2 are decompressed to SFNT first. Output is always written as .ttf.
Will it choke on Apple Color Emoji?
Apple Color Emoji usually ships as a .ttc collection, which is rejected — split a single face out first. A single-face sbix font is fine, but its outline fallback is minimal, so the monochrome result is sparse.
Does the runner upload my fonts anywhere?
No. The runner processes fonts locally and the HTTP API is bound to 127.0.0.1. Font bytes never reach JAD's servers, which matters for unreleased or licensed brand fonts in a build.
Why might my monochrome font be bigger than the colour one?
Because the input was a compressed WOFF/WOFF2 and the output is an uncompressed TTF. Removing tables shrinks table count, but decompression inflates the rest. Re-compress to WOFF2 and the monochrome version becomes smaller.
Should I cache the step?
Yes — key a cache on the source font hash. The strip is cheap but the WOFF2 recompression of large fonts isn't, so caching unchanged inputs keeps CI fast.
Where's a complete pipeline example?
The GitHub Actions snippet above (strip → woff2_compress → size-budget gate) is a working starting point. Pair it with the opentype.js verify step so a leaked colour table fails the build.
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.