How to automate svg font generation for pdf build pipelines
- Step 1Decide and lock the charset — Pipelines that generate templated PDFs (invoices, reports, certificates) render a small, stable character set. Determine it once and subset every font to it with the [Font Subsetter](/font-tools/font-subsetter) — manually for the design baseline, or with fontTools/hb-subset in CI. Subsetting keeps you under the 5,000-glyph cap and shrinks the XML.
- Step 2Mirror the conversion logic in Node — Install `opentype.js`. Parse each subset TTF with `opentype.parse`, read `unitsPerEm`, ascender, descender, then loop glyph indices 0..min(glyphs.length, 5000). Emit `<missing-glyph>` from index 0 and a `<glyph>` for each glyph with a Unicode codepoint, using `getPath(0,0,upm).toPathData(2)` for `d` — identical to the browser handler.
- Step 3Or POST to the JAD runner — On Pro/Developer, pair the @jadapps/runner once, then `GET /api/v1/tools/font-to-svg-font` for the schema (it has no options) and `POST` the font to `http://127.0.0.1:9789/v1/tools/font-to-svg-font/run` with the file as multipart form data. The font never leaves your machine — the runner executes the same handler locally.
- Step 4Batch the directory — Glob your `src/fonts/*.{ttf,otf,woff,woff2}`, run the converter per file, and write `dist/fonts/<stem>.svg`. If you start from WOFF/WOFF2, decompress to sfnt first (wawoff2 for WOFF2, pako/inflate for WOFF) exactly as the browser tool does, or simply keep TTF masters in the repo.
- Step 5Add a CI guard — After conversion, assert two things: Glyphs-exported equals your subset character count (catches missing-codepoint glyphs), and each `.svg` is under a size budget (catches an un-subset font slipping through). Fail the build on either, so a broken font is caught before it reaches the PDF stage.
- Step 6Cache by font hash + charset — Conversion is deterministic for a fixed source and charset, so cache the `.svg` keyed on `hash(font) + charset`. Unchanged fonts skip regeneration, keeping CI fast. Pin the opentype.js version too so a library bump doesn't silently change path output.
Three ways to automate the conversion
Pick by what your build box already has. All three produce the same outline-only SVG Font; none preserves kerning or OpenType features.
| Approach | Dependencies | Best for |
|---|---|---|
| Pure Node + opentype.js | opentype.js only | JS/TS pipelines; no Python; full control |
| JAD runner HTTP API | Paired @jadapps/runner (Pro/Dev) | Running the exact JAD handler unattended, locally |
| Browser tool (manual) | None | One-off conversions; not for CI |
Conversion constants to assert against
The fixed behaviours of the logic. Bake these into CI assertions so violations fail the build instead of corrupting PDFs.
| Constant | Value | CI assertion idea |
|---|---|---|
| Glyph cap | 5,000 indices | Source glyph count ≤ 5,000 after subset |
| Codepoint requirement | unicode required (except index 0) | Glyphs-exported == expected charset size |
| Path precision | 2 decimals (toPathData(2)) | Pin opentype.js version for stable bytes |
| Free file limit (browser/runner free) | 5 MB | Subset before convert; or use a paid tier |
| Features carried | Outlines + advances only | Don't expect/require kerning or liga in PDF |
Runner API surface for this tool
The HTTP contract when you offload to the local runner. The tool takes a file and no options.
| Call | Purpose | Notes |
|---|---|---|
GET /api/v1/tools/font-to-svg-font | Fetch the schema | options: [] — file-only tool |
POST 127.0.0.1:9789/v1/tools/font-to-svg-font/run | Run the conversion locally | Multipart: the font file; returns the SVG |
| Output MIME | image/svg+xml | Single SVG document |
| Privacy | Local execution | Font never reaches JAD servers |
Cookbook
Copy-pasteable automation. The Node script reproduces the browser handler exactly; the runner recipe offloads to the local API. Subset first in every case.
Pure-Node converter (mirrors the JAD handler)
ExampleA standalone Node script using opentype.js. Same glyph walk, same 5,000 cap, same codepoint rule, same 2-decimal path precision as the browser tool.
// svg-font.mjs — node svg-font.mjs MyFont.ttf
import fs from 'node:fs';
import opentype from 'opentype.js';
const esc = s => s.replace(/[&<>"']/g, c =>
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const path = process.argv[2];
const font = opentype.parse(fs.readFileSync(path).buffer);
const upm = font.unitsPerEm;
const ascent = font.ascender ?? upm * 0.8;
const descent = font.descender ?? upm * -0.2;
const family = font.getEnglishName('fontFamily') || 'font';
const sub = font.getEnglishName('fontSubfamily') || 'Regular';
let body = '';
const limit = Math.min(font.glyphs.length, 5000);
for (let i = 0; i < limit; i++) {
const g = font.glyphs.get(i);
if (g.unicode == null && i !== 0) continue;
const adv = g.advanceWidth ?? upm;
const d = g.getPath(0, 0, upm).toPathData(2);
body += i === 0
? `\n <missing-glyph horiz-adv-x="${adv}" d="${d}"/>`
: `\n <glyph unicode="${esc(String.fromCodePoint(g.unicode))}"` +
`${g.name ? ` glyph-name="${esc(g.name)}"` : ''}` +
` horiz-adv-x="${adv}" d="${d}"/>`;
}
const weight = sub.toLowerCase().includes('bold') ? 'bold' : 'normal';
fs.writeFileSync(path.replace(/\.[^.]+$/, '.svg'),
`<?xml version="1.0" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg"><defs>
<font id="${esc(family)}" horiz-adv-x="${upm}">
<font-face font-family="${esc(family)}" font-weight="${weight}"
units-per-em="${upm}" ascent="${ascent}" descent="${descent}"/>${body}
</font>
</defs></svg>\n`);Batch a directory of fonts
ExampleLoop the converter over every TTF/OTF in a folder, emitting one SVG per font. Keep TTF masters in the repo so you don't need a WOFF2 decompress step in CI.
import { glob } from 'glob';
import { execFileSync } from 'node:child_process';
const fonts = await glob('src/fonts/*.{ttf,otf}');
for (const f of fonts) {
execFileSync('node', ['svg-font.mjs', f], { stdio: 'inherit' });
}
console.log(`Converted ${fonts.length} fonts → SVG.`);Offload to the local JAD runner
ExampleOn Pro/Developer, run the exact JAD handler locally over HTTP. The font is sent only to 127.0.0.1, never to JAD's servers.
curl -sS -X POST http://127.0.0.1:9789/v1/tools/font-to-svg-font/run \ -F 'file=@dist/fonts/Brand-Regular.subset.ttf' \ -o dist/fonts/Brand-Regular.svg # Inspect the no-options schema first: curl -sS http://localhost:3000/api/v1/tools/font-to-svg-font | jq '.options' # → []
CI guard: count and size budget
ExampleFail the build if a font lost glyphs (missing codepoints) or wasn't subset (oversized SVG). Cheap checks that catch the two most common PDF font defects before render.
import fs from 'node:fs';
const EXPECTED_GLYPHS = 80; // your locked charset size
const MAX_BYTES = 60_000; // size budget per SVG
for (const svg of fs.readdirSync('dist/fonts').filter(f => f.endsWith('.svg'))) {
const xml = fs.readFileSync(`dist/fonts/${svg}`, 'utf8');
const glyphs = (xml.match(/<glyph /g) || []).length;
const bytes = Buffer.byteLength(xml);
if (glyphs < EXPECTED_GLYPHS)
throw new Error(`${svg}: only ${glyphs} glyphs (missing codepoints?)`);
if (bytes > MAX_BYTES)
throw new Error(`${svg}: ${bytes}B over budget — subset it`);
}Deterministic caching by font hash
ExampleSkip regeneration when nothing changed. Conversion is deterministic for a fixed source + opentype.js version, so a content hash is a safe cache key.
import { createHash } from 'node:crypto';
import fs from 'node:fs';
function cacheKey(fontPath, charset) {
const h = createHash('sha256');
h.update(fs.readFileSync(fontPath));
h.update(charset);
return h.digest('hex').slice(0, 16);
}
// Store dist/fonts/<stem>.<key>.svg and reuse if the key matches.
// Pin opentype.js in package.json so path bytes don't drift.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 version bump changes path bytes
Reproducibility riskPath output comes from opentype.js toPathData(2). An unpinned dependency can change rounding or command emission across versions, producing different SVG bytes and busting your cache. Pin the exact opentype.js version in package.json and treat a deliberate bump as a content change you re-baseline.
A font slipped through un-subset
Oversized — caught by budgetIf a full font reaches the converter, the SVG can be hundreds of KB of uncompressed XML, and a large CJK font silently truncates at 5,000 glyphs. The size-budget CI guard catches the bloat; the glyph-count guard catches the truncation. Always subset to the document charset upstream of the converter.
Glyphs-exported is lower than the charset size
Missing codepointsConversion exports only codepointed glyphs. If your charset relies on glyphs reached via ligature/substitution (no unicode), those won't appear and Glyphs-exported drops below the expected count. The count CI guard fails the build; fix by relying on codepointed glyphs or embedding the TTF natively for true ligatures.
WOFF/WOFF2 input in CI without a decompressor
Extra step requiredThe browser tool decompresses WOFF/WOFF2 transparently; a minimal Node script using only opentype.parse expects an sfnt. Either keep TTF masters in the repo, add wawoff2 (WOFF2) / pako-inflate (WOFF) to decompress first, or use the runner API which handles all four formats. The simplest CI is TTF-in.
Free-tier file limit hit by the runner
413 / rejectedOn the free tier, files over 5 MB are rejected (Pro 50 MB, Developer 1 GB). A large unsubset source font trips this. Subset before conversion, or run on a paid tier. The pure-Node script has no such limit — it's your own opentype.js call — but you still want to subset for size and the 5,000-glyph cap.
Expecting kerning/ligatures in the PDF
Not exportedThe conversion drops kerning, ligatures, and all GPOS/GSUB. PDFs rendered from the SVG Font show raw advances and unsubstituted glyphs. If the PDF needs real shaping, embed the original TTF/OTF natively in your PDF engine instead — see the PDF pipeline integration guide.
Variable font exported at defaults
Default instance onlyThe glyph walk uses default-axis master outlines; it does not flatten a chosen weight/width. A variable font yields one static SVG Font at its defaults. Freeze the instance you want first with the Variable Font Freezer (mind its gvar caveat) or instance with fontTools varLib.instancer in CI, then convert.
Non-deterministic glyph order assumptions
Stable by indexThe converter walks glyphs by index 0..N, so glyph order in the SVG is the source font's glyph order, which is stable for a fixed font. Don't write CI checks that assume codepoint-sorted order — sort yourself if you need it. The order is deterministic but follows glyph index, not Unicode value.
Frequently asked questions
Can I generate SVG Fonts without Python or native binaries?
Yes. The conversion is pure opentype.js: parse the sfnt, read units-per-em and metrics, walk glyph indices, and serialise <glyph> elements. The Node script in the cookbook above reproduces the browser handler exactly with one dependency (opentype.js) — no fontTools, no Python, no native font tooling.
How do I run the exact JAD handler in CI?
Pair the @jadapps/runner (Pro/Developer), then POST the font to http://127.0.0.1:9789/v1/tools/font-to-svg-font/run as multipart form data. It runs the same handler locally and returns the SVG. The font never leaves your machine. GET /api/v1/tools/font-to-svg-font returns the schema (which has no options).
What should I subset to before converting?
Your document's actual character set. PDF templates render a small, stable charset, so subset every font to it with hb-subset, pyftsubset, or the Font Subsetter. This keeps you under the 5,000-glyph cap and shrinks the otherwise-verbose SVG to a few KB.
How do I keep the build reproducible?
Pin the opentype.js version (path bytes depend on it), fix the subset charset, and version-pin the source fonts. Same inputs → identical SVG bytes every run. Cache the output keyed on hash(font) + charset so unchanged fonts skip regeneration.
Will the automated output include kerning or ligatures?
No — same as the browser tool. The logic emits glyph outlines and advance widths only, no <hkern>, no GSUB/GPOS. If your PDFs need shaping, embed the original TTF/OTF natively in the PDF engine instead of using the SVG Font.
What's the maximum glyph count in CI?
5,000, enforced by Math.min(glyphCount, 5000) in the logic (and in the Node script). A larger font truncates silently. Add a CI assertion that the post-subset glyph count is well under 5,000 so you never ship a truncated font.
How do I handle WOFF/WOFF2 sources in a Node script?
Decompress to sfnt first: wawoff2 for WOFF2 (Brotli), pako inflate for WOFF (zlib), exactly as the browser tool does. The simplest pipeline keeps TTF masters in the repo so no decompression is needed. The runner API accepts all four formats directly.
How do I fail the build when a font is broken?
Two cheap guards: assert Glyphs-exported equals your expected charset size (catches missing-codepoint glyphs), and assert each SVG is under a byte budget (catches un-subset fonts). The CI guard example in the cookbook implements both and throws on violation.
Does the runner have a file size limit?
It mirrors the tier limits: 5 MB free, 50 MB Pro, 1 GB Developer. Subset large fonts before sending, or run a paid tier. A pure-Node opentype.js script you own has no such limit, but you should still subset for size and to stay under the 5,000-glyph cap.
Is glyph order in the output deterministic?
Yes — glyphs are emitted in source glyph-index order (0..N), which is stable for a fixed font. Note it's index order, not Unicode-sorted order. If a downstream consumer expects codepoint order, sort the <glyph> elements yourself after generation.
Can I batch convert a whole design system at once?
Yes — glob your font directory and run the converter per file, writing one .svg per font. The batch example in the cookbook does exactly this. Subset each font first and the whole run produces small, deterministic artifacts suitable for committing or caching.
Should I commit the SVG Fonts or generate them per build?
Either works; committing is simplest and most reproducible for stable fonts, while per-build generation suits frequently-changing assets. If you generate per build, cache by font hash + charset and pin opentype.js so output bytes don't drift between runs.
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.