· font technology, webfonts, tools, measurements
Subsetting fonts, measured: five tools, one spec
Subsetting is the practice of cutting a font file down to the characters a project actually needs, discarding the rest so the file ships smaller. Five common ways to do it are fontTools’ pyftsubset, HarfBuzz’s hb-subset, the JavaScript libraries fontkit and opentype.js, and the Rust subsetter crate. We ran all five on the same fonts and the same characters, and then checked what their output really holds. The results include a warning worth knowing before you ship: two of the tools produce files with no character map at all, which means the output is not a font anything can open, and neither tool tells you.

The test: one spec, checked properly
The bench used two familiar open fonts in their regular weights, Inter and Playfair Display, and asked every tool for the same 75 characters: the alphabet in both cases, the digits, and basic punctuation, with layout features such as ligatures and kerning kept and hinting switched off. Each output was then measured the same way: its byte size and glyph count, whether its character map (the cmap table, the index that connects characters to glyphs) matches the spec exactly, whether kerning survived, and whether Playfair’s ffi ligature still forms when the word “office” is shaped.
That verification step matters because subsetting tools rarely fail loudly. They produce a file of a plausible size, report success, and leave you to discover what is missing when text renders wrong, or does not render at all.

Output sizes for the same 75 characters. The headline trap of the whole test: the two smallest outputs have no cmap, so no system can use them as fonts.
pyftsubset: the faithful one
fontTools is the Python library most font tooling is built on, and its subsetter is the conservative choice:
pyftsubset Inter-Regular.ttf \
--unicodes="U+0020,U+0021,..." \
--layout-features='*' --no-hinting
Told to keep every layout feature, pyftsubset keeps nearly everything: not just ligatures and kerning but the stylistic alternates most people never think to ask about, such as Inter’s character variants and Playfair’s small caps, each verified working afterwards by shaping with the feature switched on. The price is size. Its outputs are the largest of the working tools by a wide margin, because keeping a feature means keeping every alternate glyph the feature can reach. It is also the slowest of the group, though slowest here still means under a second a font. If fidelity matters more than bytes, this is the tool.
HarfBuzz: the smallest real font
HarfBuzz is the shaping engine inside Chrome, Firefox, Android, and most of the Linux desktop, and its subsetter, hb-subset, produced the smallest outputs that are still genuine fonts: less than half of pyftsubset’s size on the same spec, in about two hundredths of a second. The cmap matches the spec exactly, kerning survives, and Playfair’s ffi ligature shapes correctly.
One behavior deserves a label. Even when explicitly asked to keep every feature, hb-subset quietly drops the features that only offer alternates: Inter’s stylistic sets and character variants, Playfair’s small caps. What survives is everything the retained characters need to shape properly. For a webfont that is arguably the right default, since alternates are usually dead weight on a page, but if your design uses those alternates, pyftsubset is the one that keeps them. The same engine is available in the browser as WebAssembly; the hb-subset-wasm package reproduced the native results to within half a percent on both fonts.
fontkit: the smallest file is not a font
fontkit is a respected JavaScript font engine, and its subsetting API is three lines of code:
const subset = font.createSubset();
for (const cp of codepoints) subset.includeGlyph(font.glyphForCodePoint(cp));
const bytes = subset.encode();
It ran without an error and produced the smallest output of any tool we tested. It is also not a font. The file contains drawing data and metrics, and nothing else: no cmap, no name table, no layout tables. Without a cmap, no operating system or browser can map a single character to a glyph, so the file opens to nothing. Shaping the word “office” with it yields six placeholder boxes.
This is not a bug in the sense of broken code. fontkit’s subsets are designed to be embedded into PDFs by its sibling library pdfkit, which supplies its own encoding inside the document, and for that job the format is correct. The failure is one of fit and silence: used as a general font subsetter, which is how it is often recommended, it emits an unusable file and reports complete success.
opentype.js: a rebuild, not a subset
opentype.js has no subsetting API at all. The usual recipe parses the font, collects the glyphs you want, and builds a brand-new font object around them:
const font = opentype.parse(buffer);
const glyphs = codepoints.map(cp => font.charToGlyph(String.fromCodePoint(cp)));
const out = new opentype.Font({ familyName, styleName, glyphs });
The rebuild has an exact cmap and keeps every character shape, but everything that was not a glyph outline is left behind, because the new font object has no layout tables to inherit: kerning is gone entirely, and Playfair’s ligature is lost. Two quieter changes ride along. A TrueType font goes in and a CFF-flavored OpenType font comes out, its quadratic curves converted to cubics, and composite glyphs are flattened into plain outlines along the way. The result is a usable font, and for redrawing or inspecting glyphs opentype.js is a fine tool. As a subsetter it changes the font’s format and behavior while wearing the original’s name.
The Rust subsetter: fast, careful, and built for PDFs
The subsetter crate is the subsetting library Typst ships, written in pure Rust. The subsetting itself is careful work: composite glyphs are closed over correctly, and uniquely among the small-output tools it keeps the name table and the real glyph names. It is also as fast as HarfBuzz. But its own documentation states the scope plainly: the output removes the cmap by design, because PDFs supply their own, and it drops every layout table with no option to keep them. Its API takes glyph IDs rather than characters, so a caller who resolves the characters wrongly gets a wrong subset without an error.
We also compiled the same crate to WebAssembly and ran it under Node. The outputs were byte-for-byte identical to the native build, which settles a question that comes up whenever Rust tools are proposed for the browser: the compilation target is not the variable. The crate’s design scope is.
Which one to use
The pattern across the bench is that subsetting tools split into two families that are rarely labeled as such. Tools built to subset fonts, pyftsubset and hb-subset, return fonts. Tools built to feed PDF pipelines, fontkit and the subsetter crate, return embedding fragments that look like fonts until something tries to open them as one.
For shipping a webfont or an app font, reach for hb-subset when size is the priority, and pyftsubset when the font’s alternates and full feature set must survive, accepting the larger file. In the browser, the HarfBuzz engine compiled to WebAssembly gives the same results with the file never leaving the visitor’s machine. For embedding text into a generated PDF, the fragment makers are in their element, encoding supplied by the document and all. And whichever tool you choose, verify the output the way this bench did: check that the cmap holds the characters you asked for, and shape a word with a ligature and a kerned pair before the file goes anywhere near production.
Method: Inter Regular and Playfair Display Regular from Google Fonts; 75 codepoints (A-Z, a-z, 0-9, basic punctuation); layout features requested, hinting off. Tools measured locally: pyftsubset (fontTools 4.67), HarfBuzz subset library 8.3.0 driven through its C API, fontkit 2.0.4, opentype.js 2.0.0, subsetter 0.2.6 native and as wasm32; hb-subset-wasm 0.4.0 checked against the native HarfBuzz run. Cmap contents, kerning subtables, and ligature behavior verified with fontTools and HarfBuzz shaping after subsetting.