How to enforce font glyph budgets via ci script
- Step 1Pair the runner once — Install and authenticate @jadapps/runner on the build machine. It serves the same font engine locally on `127.0.0.1:9789`. Confirm it's live with `GET http://127.0.0.1:9789/v1/health` before wiring the gate.
- Step 2Confirm the empty schema — `GET http://127.0.0.1:9789/v1/tools/glyph-count-analyzer` returns `{ "options": [] }`. There are no parameters to pass — the contract is just the file in, JSON out. Pin this in your script so a future schema change is caught.
- Step 3Define per-font budgets in a config file — Keep a `fonts.budget.json` mapping each shipped font path to its limits, e.g. a max for `total_glyphs` and a max projected or real KB. Putting it in version control means a budget bump shows up in the PR diff for reviewers to approve deliberately.
- Step 4POST each font and parse the JSON — For each shipped font, `curl -F 'files=@path'` to `/v1/tools/glyph-count-analyzer/run` and pipe to jq. Read `total_glyphs` for glyph drift and the relevant `projections[].estimated_woff2_bytes` for projected size.
- Step 5Gate on projection AND real bytes — Fail the build if `total_glyphs` exceeds its cap (a clean signal of a font changing) and if the projection exceeds budget. For a strict byte gate, also `stat` the built WOFF2 on disk — that's the real shipped size, which the analyser only estimates.
- Step 6Adjust budgets through review, not in the script — When a font legitimately grows, bump `fonts.budget.json` in a PR. The diff forces an explicit acknowledgement of the growth and a reviewer's sign-off — exactly the friction a budget needs to mean something.
The JSON contract you parse in CI
Exact fields the analyser returns. These are stable because the tool has no options. Parse with jq; gate on the ones that match your budget policy.
| JSON path | Type | Gate on it when… |
|---|---|---|
.total_glyphs | integer | You want to catch a font binary changing at all (foundry update, swapped variant) |
.current_woff2_estimate_bytes | integer | Tracking the full-font baseline; real for WOFF2 input, a 0.55× estimate for TTF/OTF/WOFF |
.projections[].subset | string | Selecting the row for the subset you actually ship (e.g. starts with "Latin Basic") |
.projections[].glyph_count | integer | Checking codepoint coverage of a subset drifted |
.projections[].estimated_woff2_bytes | integer | Early-warning size budget on the subset you ship (estimate, not exact) |
.projections[].savings_pct | integer | Asserting a minimum savings is still achievable after a font change |
Projection gate vs real-bytes gate
The analyser projects; it doesn't subset. Choose the right gate for the right job. For a hard contractual byte limit, measure the actual built file.
| Gate | Source of truth | Strengths / limits |
|---|---|---|
total_glyphs drift | Analyser JSON | Best early-warning: any binary change moves it. Doesn't directly equal KB. |
| Projected subset KB | Analyser JSON | Cheap, no subset build needed. ±5% for Latin, looser for complex scripts. |
| Real subset KB | Built WOFF2 on disk (stat) | Exact shipped size. Requires actually running the subset + WOFF2 build first. |
| Baseline KB | Analyser current_woff2_estimate_bytes | Real for WOFF2 input; estimate for TTF/OTF/WOFF input. Anchor on WOFF2. |
Cookbook
Copy-paste CI snippets. They assume the runner is live on 127.0.0.1:9789 and jq is installed. For the budgeting theory behind the numbers, see glyph budget planning; to actually produce the subset these gates measure, use the font subsetter.
Fail the build if total_glyphs drifts
ExampleThe cleanest regression signal: any change to the font binary moves total_glyphs. Pin the expected number and fail on drift, so a silent foundry update can't slip in unreviewed.
#!/usr/bin/env bash set -euo pipefail EXPECTED=512 ACTUAL=$(curl -sS -X POST \ http://127.0.0.1:9789/v1/tools/glyph-count-analyzer/run \ -F 'files=@public/fonts/Body.woff2' | jq '.total_glyphs') if [ "$ACTUAL" -ne "$EXPECTED" ]; then echo "Body font glyph count changed: $EXPECTED -> $ACTUAL" echo "Review the font update, then bump EXPECTED if intended." exit 1 fi
Projection budget gate per shipped subset
ExampleRead the projection for the subset you actually ship and fail if it exceeds the budget. Early warning — projection is an estimate, so keep ~10% headroom.
JSON=$(curl -sS -X POST \
http://127.0.0.1:9789/v1/tools/glyph-count-analyzer/run \
-F 'files=@public/fonts/Body.woff2')
KB=$(echo "$JSON" | jq '.projections[]
| select(.subset|startswith("Latin Basic"))
| .estimated_woff2_bytes')
BUDGET=24000
if [ "$KB" -gt "$BUDGET" ]; then
echo "Body Latin subset projects ${KB}B > ${BUDGET}B budget"; exit 1
fiLoop a whole fonts directory against a budget file
ExampleDrive every shipped font from fonts.budget.json so adding a font means adding a budget entry, not editing the script.
jq -r 'to_entries[] | "\(.key)\t\(.value.maxGlyphs)"' fonts.budget.json |
while IFS=$'\t' read -r path maxg; do
g=$(curl -sS -X POST \
http://127.0.0.1:9789/v1/tools/glyph-count-analyzer/run \
-F "files=@$path" | jq '.total_glyphs')
if [ "$g" -gt "$maxg" ]; then
echo "FAIL $path: $g glyphs > $maxg"; FAILED=1
fi
done
[ "${FAILED:-0}" = 1 ] && exit 1 || echo "All fonts within glyph budget"Hard byte gate on the real built file
ExampleThe projection is an estimate; for a contractual byte limit, measure the actual WOFF2 you ship. Build the subset, then stat the file. Use the analyser as the early-warning layer above this.
# 1. build the real subset (font-subsetter / pipeline)
# 2. measure the real shipped bytes:
REAL=$(stat -c%s dist/fonts/Body-Latin.woff2)
if [ "$REAL" -gt 24000 ]; then
echo "Shipped Body-Latin.woff2 is ${REAL}B > 24000B"; exit 1
fi
# analyser projection is the WARNING; this stat is the HARD gate.Guard against schema drift
ExampleThe contract is stable because options are empty. Assert that in CI so a future tool change surfaces loudly rather than silently breaking your parsing.
OPTS=$(curl -sS http://127.0.0.1:9789/v1/tools/glyph-count-analyzer \ | jq -c '.options') if [ "$OPTS" != '[]' ]; then echo "glyph-count-analyzer schema changed: options=$OPTS" echo "Re-check the CI assumptions before trusting the gate." exit 1 fi
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.
Gating on projection as if it were exact bytes
Estimate, not exactestimated_woff2_bytes is current × proportion × 1.25, floored at 1500 — a model, not a measurement. Treating it as a hard byte contract will occasionally fail or pass wrongly, especially for complex scripts. Use the projection as an early-warning gate with headroom, and for a strict byte limit stat the real built WOFF2. The analyser is for catching drift cheaply; the file system is for the exact number.
Baseline is a 0.55× guess for TTF input
ExpectedIf your CI feeds the analyser a TTF/OTF/WOFF, current_woff2_estimate_bytes is sfnt × 0.55 — an assumption, not a measurement, and every projection scales off it. A heavily-hinted font breaks the 0.55× assumption and your size gates inherit the error. Feed the analyser the actual WOFF2 you ship so the baseline is real, or strip hinting first with the hinting stripper.
total_glyphs gate fires on a legitimate update
Expected (review and bump)A total_glyphs equality gate fires on any binary change — including intended ones. That's the point: it forces a human to look. When the change is legitimate, bump the expected number in fonts.budget.json within the same PR, so the growth is acknowledged in the diff. Don't loosen the gate to a range to silence it; the friction is the value.
Runner not running, curl fails, gate is skipped
Pipeline errorIf the runner isn't up on 127.0.0.1:9789, the curl fails and — without set -e — your script may carry on and effectively skip the gate, giving a false green. Start every script with set -euo pipefail and probe GET /v1/health first, failing the build if the runner is unreachable. A budget gate that silently no-ops is worse than no gate.
File over the tier size limit returns an error, not JSON
413-style rejectA font over the active tier's size limit (free 5 MB / pro 50 MB / developer 1 GB) is rejected before parsing, so the response is an error rather than the JSON you jq on. Your gate must handle a non-zero curl status and a non-JSON body, not assume success. Run CI under a tier whose limit clears your largest font, and check the HTTP status before parsing.
jq selects the wrong subset row
Logic bugSelecting by startswith("Latin Basic") is robust, but a loose match like contains("Latin") hits both "Latin Basic + Latin-1 Supplement" and "Latin Extended-A + Extended-B", returning two values and breaking the numeric comparison. Match the exact subset label you ship. The six labels are fixed strings, so anchor on them precisely.
A .ttc collection in the build trips the engine
Rejected (unsupported format)If a TrueType Collection (.ttc) sneaks into the fonts directory, the run returns Unsupported font format: ttc rather than JSON, and a naive loop logs a confusing parse error. Filter the directory to .ttf/.otf/.woff/.woff2, or detect and skip collections explicitly. The analyser only handles single faces.
Icon font makes the size gate meaningless
Expected (low coverage)Icon fonts map to the Private Use Area, which none of the six subsets cover, so every projection shows near-100% savings and a near-floor size. A size gate on the projected subset would pass trivially while telling you nothing — subsetting to "latin" would delete every icon. For icon fonts, gate on total_glyphs drift and on the real built file size, not the subset projection.
Frequently asked questions
How do I run the analyser without a browser?
Through the JAD runner. Pair @jadapps/runner once, then POST the font to http://127.0.0.1:9789/v1/tools/glyph-count-analyzer/run as a multipart files field. You get the same JSON the browser produces — total_glyphs, current_woff2_estimate_bytes, and projections[]. The runner uses the same JS/WASM engine, so CI and the UI agree on the numbers.
What parameters does the run endpoint take?
None. The schema is empty — GET /api/v1/tools/glyph-count-analyzer (or the runner's GET /v1/tools/glyph-count-analyzer) returns { "options": [] }. You only send the file. That makes the contract stable, but assert the empty schema in CI so a future change surfaces loudly instead of silently breaking your parsing.
Should I gate on the projection or the real file size?
Both, for different reasons. The projection (estimated_woff2_bytes) is a cheap early-warning gate but only an estimate (±5% Latin, looser elsewhere). For a hard byte limit, build the real subset and stat the shipped WOFF2 — that's exact. Use the analyser to catch drift before the build, and the file size for the contractual limit.
What's the best single number to catch a font regression?
total_glyphs. Any change to the font binary — a foundry update adding glyphs, a swapped variant, a re-export with different features — moves it. Pin the expected value and fail on inequality. It won't tell you the KB directly, but it's the most reliable tripwire that the font changed at all, which is when you should look at size.
Why is the baseline different for TTF vs WOFF2 input?
For WOFF2 the baseline is the real file.size; for TTF/OTF/WOFF it's sfnt bytes × 0.55, a typical-compression estimate. Since every projection scales off the baseline, feeding CI the actual shipped WOFF2 makes all the numbers real. A heavily-hinted TTF breaks the 0.55× assumption — analyse the WOFF2 instead, or strip hinting first.
How do I keep the script from skipping the gate when the runner is down?
Start with set -euo pipefail and probe GET http://127.0.0.1:9789/v1/health before any run, failing the build if it's unreachable. Without that, a failed curl can let the script continue and report green — a budget gate that silently no-ops is the worst outcome. Treat runner-unreachable as a hard build failure.
How should I structure the budget config?
A version-controlled fonts.budget.json keyed by font path, with per-font limits (e.g. maxGlyphs, maxKB). Loop it in the script so adding a font is a config change, not a script edit. The real value is that any budget bump appears in the PR diff, forcing reviewers to consciously approve the growth instead of it slipping in.
Can I also enforce coverage in the same pipeline?
Yes — pair this with the character coverage map via its own runner endpoint. Gate size with the glyph count analyser and language fitness with the coverage map's per-block percentages. The glyph count vs coverage guide explains why both gates are needed and what each catches.
Does the runner upload my fonts to JAD's servers?
No. The runner executes the engine locally on your machine; font bytes stay on the build host and never reach JAD's servers. That's the point of the local HTTP API — CI can process confidential or licensed fonts without exposing them. Only signed-in dashboard usage counters (no content) are recorded, and they're opt-out.
What happens if a non-font or a .ttc file is in the directory?
A TrueType Collection returns Unsupported font format: ttc, and a non-font fails format detection — both return an error, not the JSON you parse. Filter your loop to .ttf/.otf/.woff/.woff2 and check the HTTP status before piping to jq, so a stray file produces a clear failure rather than a confusing parse error.
How much headroom should the projection budget have?
Around 10% above the real shipped size, because the projection is an estimate and a small foundry update shouldn't trip it. If the real Latin subset is 19 KB, set the projection gate near 24 KB. Pair it with a tighter hard gate on the actual built file for the contractual limit. The projection catches trends; the file stat catches breaches.
Does the analyser produce the subset my real-bytes gate measures?
No — it only counts and projects. To produce the actual subset file you stat, run the font subsetter (or your existing build step) with the charset the analyser recommended, then convert to WOFF2 with the TTF to WOFF2 tool if needed. The analyser's job in CI is to flag drift cheaply, upstream of 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.