Two weeks ago I wrote that next/font/google broke on an ampersand, so our two fonts are 59KB in the repo now. That post was about the switch: why the build broke, what the diff looked like, and what weight: "300 700" means next to a variable font.
This one is about what is actually inside the two files, because self-hosting a font changes a subset from something a build step chose into an artifact sitting in your repository that you can open and count. So I opened them.
Notifio is a desktop app that watches rental search pages for new listings. The site in front of it is a Next.js app, and it loads three typefaces: Space Grotesk for headings, DM Sans for body text, and Geist Mono for small labels.
const spaceGrotesk = localFont({
src: "./fonts/space-grotesk-latin.woff2",
variable: "--font-space-grotesk",
weight: "300 700",
display: "swap",
preload: true,
});
Two of the three are files in server/src/app/fonts/: 22,320 bytes of Space Grotesk and 36,980 bytes of DM Sans. The third comes from the geist npm package.
What is in the files
Four lines of Python, with fonttools and brotli installed:
from fontTools.ttLib import TTFont
t = TTFont("src/app/fonts/dm-sans-latin.woff2")
print(t["maxp"].numGlyphs, len(t.getBestCmap()))
for a in t["fvar"].axes:
print(a.axisTag, a.minValue, a.defaultValue, a.maxValue)
| Space Grotesk | DM Sans | |
|---|---|---|
| Bytes | 22,320 | 36,980 |
| Glyphs | 291 | 270 |
| Codepoints in cmap | 230 | 222 |
wght axis |
300 to 700, default 300 | 100 to 1000, default 400 |
| Internal family name | Space Grotesk Light | DM Sans 9pt |
Two things in that table were news to me.
The first is that our declared weight: "300 700" is accurate for Space Grotesk and a deliberate narrowing of DM Sans, whose axis actually runs from 100 to 1000. Narrowing it is harmless here, and I checked rather than assumed: the site uses font-bold 95 times, font-semibold 98 times, font-medium five times and font-light twice, so nothing on any page asks for a weight outside 300 to 700. If somebody adds font-extrabold to a heading, they will get a synthesised bold rather than the 800 the file can genuinely draw.
The second is the internal family names. The Space Grotesk file calls itself "Space Grotesk Light" with a default instance at 300, and the DM Sans file calls itself "DM Sans 9pt", which is the name of one optical size of a font whose optical size axis was dropped on the way out of Google Fonts. None of that matters to next/font/local, which generates its own font-family name and never reads those strings. It matters the moment somebody copies one of these files into a hand-written @font-face and trusts the name in it.
The 20 characters our source actually uses
Before checking coverage I wanted to know what the site asks for. Across 80 source files, the copy and markup contain 20 distinct characters above U+007F. The top one is not close:
| Character | Uses |
|---|---|
U+2500 ─ box drawing |
602 |
U+20AC €
|
68 |
U+2013 – en dash |
64 |
U+00A3 £
|
60 |
| U+2014 em dash | 56 |
U+00B7 ·
|
23 |
U+2192 →
|
11 |
Then a long tail: …, ², ¥, ä, ó, ú, ö, ©, curly quotes, and one each of ✓, ⋮ and ⚙.
Both fonts cover £, €, the dashes, the middot, the ellipsis, the accented vowels and the quotes. They do not cover five of the characters in that list: ─, →, ✓, ⋮ and ⚙.
The 602 box-drawing characters turned out to be the easiest finding of the week to explain:
{/* ── Hero ── */}
{/* ── Feature demo rows ── spacing matches between all sections */}
They are in JSX comments. The single most common non-ASCII character in the repository never reaches a browser, which is also why nobody has ever noticed that neither webfont can draw it.
The other four do reach a browser. Our 56 em dashes are a different kind of embarrassment, documented separately, since the copy rule for this site says there are not supposed to be any.
The characters that fall back, and where you can see them
Three of the five are in rendered text, and the counts are in the HTML if you want to check them:
-
→in link text: four on the homepage, ten on the comparison hub, six on the help page. -
⚙twice on the help page, in a Gmail instruction that says to click the gear icon. -
⋮once on the help page, for Gmail's three-dot menu. -
✓once on the post-checkout success page, whichrobots.txtdisallows, so nobody but a buyer sees it.
Geist Mono, from the npm package, carries 889 codepoints and has the box drawing character and the arrow. It does not have the tick, the vertical ellipsis or the gear. Those three come from the operating system on every page load, in whatever the browser picks next out of ui-sans-serif, system-ui, sans-serif.
This is not a bug. Per-character fallback down the CSS font stack is exactly what should happen, and an arrow in SF Pro next to body text in DM Sans is something I only noticed because I went looking. It is still worth knowing which characters on your own site are not being drawn by the fonts you chose.
The six the repository cannot show you
Here is the part I did not expect, and the reason this is a post rather than a note in a commit message.
Notifio prices in 24 currencies, with the figure on the page being the figure charged. The symbols for those currencies are not in any source file, because Intl.NumberFormat produces them at runtime from a currency code and a locale. A grep of the repo cannot find them, so no amount of auditing the source tells you whether the fonts can draw them.
Formatting the same amount in all 24 and checking each character against both cmaps gives six that neither file covers:
| Currency | Rendered | Missing |
|---|---|---|
| PLN | 2499 zł |
U+0142 ł
|
| CZK | 2 499 Kč |
U+010D č
|
| TRY | ₺2.499 |
U+20BA ₺
|
| INR | ₹2,499 |
U+20B9 ₹
|
| JPY | ¥2,499 |
U+FFE5 fullwidth ¥
|
| ILS | 2,499 ₪ |
U+20AA ₪, plus two U+200F marks |
The Polish and Czech ones are a subset choice: ł and č live in Google's latin-ext, and we asked for latin, both before and after self-hosting, so this is not something the switch to local files lost. Of the other four, the rupee, lira and shekel signs do fall inside latin-ext's declared unicode range, so that subset might carry them depending on what the font itself draws, and the fullwidth yen sits outside every Latin range there is.
The visible consequence is on the pricing page, where the headline price is rendered at 72px in Space Grotesk. A visitor in India gets ₹ at 72px from a system font, hard against digits at 72px from ours, with whatever difference in weight and stroke that font happens to have. The ¥ that appears three times in our own source is U+00A5, the half-width one, which both fonts do have. Japan gets U+FFE5 from Intl, which they do not.
What I am going to do about it
Not add latin-ext. It fixes two of the six for certain, maybe three more, and charges every visitor the extra bytes to find out.
The fix that matches the problem is to stop relying on the symbol for the currencies where the symbol is ambiguous or missing anyway. The line under every price already names the currency, because $39.99 needs to tell a Canadian which dollar it is. For the six above, rendering the ISO code next to the number is both a legibility win and a coverage win, and it is one branch in one formatter rather than another font file.
The thing I would take from this generally: subsetting is usually discussed as a performance decision, and it is also a decision about which of your strings are allowed to be drawn in your own typeface. The strings that catch you out are the ones your code generates rather than the ones you typed, which means you cannot audit them by reading the repository. You have to format one of everything and compare it against the cmap.
If you want to look at the pages involved, the prices are on the pricing page, the arrows are on the homepage and the comparison pages, and the gear and the three dots are in the Gmail setup steps on the help page. The per-site alert pages are at notifio.app/alerts, and the app itself is on the download page.
Top comments (0)