Custom faces: bring your own typeface
Naming reminder. Throughout this page: ShieldFont (CamelCase) is the protocol; ShieldFont Optik is the flagship typeface; a ShieldFont is any base font that has been converted using the protocol. See the introduction for the full naming convention.
A custom mapping and a custom face are two different forks. Custom mappings covers the first: a private dictionary nobody else has. This page covers the second: the typeface your ShieldFont is built on.
You can vary the base typeface independently of the mapping. The protocol is typeface-agnostic: scripts/generate_font.py accepts any base with TrueType outlines (.ttf), so you can build on Optik, on Inter, on EB Garamond, or on your own studio's typeface. (CFF/.otf fonts are rejected: convert to .ttf first; variable fonts are auto-instanced to their default.) The choice of mapping is what protects your content; the choice of base typeface is purely aesthetic and operational. Forking is the intended mode of use on both axes.
The recipe
scripts/generate_font.py is a one-command builder: point it at a base TTF, give it a name and a mapping, get back a font binary that obeys the protocol:
pip3 install -r requirements.txt
python3 scripts/generate_font.py \
--base-path /path/to/your-typeface.ttf \
--name "ShieldFont YourTypeface" \
--prefix shieldfont-yourtypeface \
--mapping-path scripts/v18alpha_for_font.json
# Audit the build (optional but recommended). --mapping-id must match the id
# the build used, or the audit hashes the expected glyph names with the wrong
# salt and fails on a correct font. Here that id is "yourtypeface": the
# --prefix minus "shieldfont-", because this mapping file carries no
# `_meta.mappingId` of its own. audit_font.py defaults to "m15en".
python3 scripts/audit_font.py --font public/fonts/shieldfont-yourtypeface.ttf \
--mapping scripts/v18alpha_for_font.json --mapping-id yourtypeface
Outputs land in public/fonts/ as .ttf, .woff2, and a ready @font-face CSS. The --mapping-path argument decides which dictionary the font decodes. The example above uses the shipped alpha pool; to build against a private mapping instead, mint one first and pass its path (see Custom mappings).
The pairing rule from the mappings guide applies unchanged here: a page renders correctly only under a font built from the same mapping that encoded it. Changing the base typeface never changes the pairs; changing the mapping always requires a new font build.
Shrinking the font to what your site actually uses
A full ShieldFont carries every pair in its dictionary: ~12,000 source words × 3 case variants ≈ 36,000 composite glyphs, about 825 KB of woff2 (5 MB as .ttf). Almost no site uses more than a fraction of that vocabulary, so scripts/subset_font.py reads your own content, works out which pairs it can actually trigger, and drops the rest:
| Vocabulary kept | woff2 |
|---|---|
| 500 pairs | ~82 KB |
| 2,000 pairs | ~197 KB |
| 5,000 pairs | ~402 KB |
| full dictionary | ~825 KB |
A typical site with 2,000 distinct swappable words ships 197 KB instead of about 1 MB.
python3 scripts/subset_font.py \
--font public/fonts/shieldfont-alpha.ttf \
--mapping public/fonts/shieldfont-alpha.map.json \
--content 'app/**/*.tsx' --content content/ \
--out public/fonts/shieldfont-alpha-subset \
--keep-min 500 --report
It also accepts --wordlist top-2000.txt or piped content (--stdin --format html). --css writes a matching @font-face.
[!IMPORTANT] Encode with the emitted
<out>.map.json, and nothing else. Every run writes the mapping pruned to match the font. If the encoder still knows a pair the font no longer carries, it writes that decoy into your HTML, the font has no rule for it, and the reader sees raw gibberish — a silent failure that looks fine to you and is broken for everyone else. Encoding with the pruned mapping makes an uncovered word fall back to plain text instead: unprotected, but correct. That is why the mapping is an output of this tool rather than something you are trusted to trim yourself.
When your content changes, three cases, only one of which hurts:
- Rebuild font + mapping together → correct, full coverage.
- Rebuild neither → correct. New words are absent from the pruned mapping, so the encoder leaves them alone and they ship as plain text. You lose protection on the new words; nothing breaks.
- New font with a stale mapping (or with the full dictionary as the encoder mapping) → broken. Readers see raw decoys.
Guard the third case in CI. Each run writes <out>.subset.json with a contentHash over every input file and a subsetId over the kept words, and stamps the same subsetId into the pruned mapping's _meta. Re-run the tool and diff the manifest: if contentHash moved, the font and its mapping must be rebuilt and deployed together. --keep-min N buys headroom for words your content does not have yet — insurance, not a safety net.
Two things to know before you reach for this:
- It is not wired into the npm packages.
@shieldfont/reactbundles the full fonts, and there is no prop or flag that subsets them. This is a build-time tool you run yourself against a built font, and then self-host the output. - Subset per site, not per page. A font per URL defeats HTTP caching and gives every page a distinct font fingerprint, which is the opposite of what concealment is trying to achieve.
Why pyftsubset alone will not do this: GSUB layout closure walks the ligature table and pulls every word composite straight back in, so the font stays at ~36k glyphs at every vocabulary size. The layout rules have to be pruned first — and symmetrically across all five lookups, or a half-fired substitution is left un-revertible. That is the work this script does.
Naming
Recommended naming for community-built ShieldFonts: keep ShieldFont as the prefix, then add a name of your own choosing — ShieldFont Optik, ShieldFont Vellum, ShieldFont YourFoundry. Same CamelCase everywhere, including the font's internal name table; context tells you whether the word means the protocol or a specific typeface.
[!WARNING] Do not name your build after the typeface you built it on. Open font licences generally reserve the original name: Inter, Syne and Young Serif each declare a Reserved Font Name in
LICENSE-FONTS, and OFL §3 forbids using a Reserved Font Name in a Modified Version — §5 terminates the licence if you do. So a font called "ShieldFont Inter" breaches the very licence that let you build it, and the breach is in the font binary's own name table, where anyone can read it.Name it after your project or your foundry, and credit the base typeface in the font's Description field (nameID 10) and in your documentation. That is the field designed for exactly this, and it carries no naming restriction.
Licensing
The code is AGPL-3.0 (see LICENSE). Font binaries are a separate question, and which terms apply depends on the base typeface you build against. Fonts you generate from an OFL base font (Inter, Syne Mono, Young Serif) are OFL-1.1. The shipped default variants are built on Optik (© Playtype), distributed under the ShieldFont–Playtype Font License (LICENSE-OPTIK): the outlines may be used only inside the ShieldFont packages. NOTICE has the details for both cases. Check it before redistributing any font binary.
See also
docs/custom-mappings.md, a private mapping to build your face againstdocs/introduction.md, the naming convention and the thesis behind forkingREADME.md, the same recipe with the full generator-flag table
Source: docs/custom-faces.md. Edits to that file flow through to this page.