I generate Open Graph cards — the 1200×630 images social feeds unfurl — for a few of my sites. lans.cloud's ~65 tools each get a branded card built as an SVG string and rasterized to PNG with sharp. It works great, with one quiet dependency I'd been meaning to fix: the SVG says font-family="DejaVu Sans" and just hopes the machine running the script has that font. On my Mac it does. In a CI container, on a colleague's machine, on the next machine I migrate to — who knows. The output would still be a valid PNG either way; it would just silently render in whatever the fallback font is.
The fix seemed obvious, and it sat on my backlog phrased as a plan, not a question: embed the TTF into the SVG as a base64 data: URI inside a @font-face rule. Browsers have supported exactly that for years. Self-contained SVG in, deterministic PNG out, no host fonts involved.
This week I finally extracted the card-rendering code into a shared package, which meant the embedding plan had to graduate from backlog note to shipped helper. House rule: a helper doesn't ship until it's proven against the real renderer. So I measured it.
The plan died twice. The second death was the interesting one.
How do you prove a renderer actually reads an embedded font?
Not by looking at the output and thinking "yeah, that looks like the font." The probe that settles it: render the same SVG twice with two visually distinct real TTFs embedded under the same family name. If the renderer reads the embedded bytes, the two PNGs must differ. If they're identical, the src was never opened.
const styleA = `<style>@font-face{font-family:'EmbedTest';src:url(data:font/ttf;base64,${geistB64}) format('truetype');}</style>`;
const styleB = `<style>@font-face{font-family:'EmbedTest';src:url(data:font/ttf;base64,${bricolageB64}) format('truetype');}</style>`;
// same card SVG around each, font-family="EmbedTest", hash the PNGs
Plus a control that proves text renders at all (card with text vs. card without — hashes must differ, or the whole probe is measuring nothing).
Death #1: librsvg doesn't do @font-face. At all.
sharp rasterizes SVG through libvips, which delegates to librsvg. My probe, against sharp 0.35.3 (librsvg 2.62.90, pango 1.58.0):
text renders at all (withText != blank): true
geist-embed hash 54e520a441bee547
bric-embed hash 54e520a441bee547
DISTINCT embedded faces differ? false
geist-embed vs no-face differ? false
Two completely different typefaces, byte-identical output — and identical to a card with no @font-face at all. The embedded src is never read. librsvg resolves fonts exclusively through fontconfig/Pango, i.e. through the host. The thing I wanted to escape is the only mechanism it has.
Fine. Plan A dead, honestly measured. But I remembered reading that resvg — a Rust SVG renderer with a solid Node binding — does handle web fonts. Swap rasterizers for the card step, keep sharp for everything else. Plan B.
Death #2 (retracted): the probe that lied
I installed @resvg/resvg-js, wrote the same two-fonts probe, and — because I "knew" the modern option was to pass fonts programmatically — used what I remembered the API to be:
new Resvg(svg, {
font: { loadSystemFonts: false, fontBuffers: [geist] }, // spoiler: no such key
}).render().asPng();
Results, across every variant I tried:
fontBuffers: geist 4e714bda0f7d19a1 bric 4e714bda0f7d19a1 differ? false
@font-face: geist 4e714bda0f7d19a1 bric 4e714bda0f7d19a1 differ? false
Everything rendered. Everything was identical. Conclusion practically writes itself: "resvg ignores embedded fonts too, and even its programmatic font API doesn't differentiate. Plan B dead."
That conclusion is false, and I nearly shipped it into a README.
What saved it was a nagging contradiction: with loadSystemFonts: false and no fonts supplied, where were the glyphs coming from? Text was rendering in something. So I ran the dumbest possible control — the flag alone, nothing else:
new Resvg(svg, { font: { loadSystemFonts: false } }).render().asPng();
// → no glyphs at all. The flag works.
No text. The flag works. Add my fontBuffers key back — text comes back. The option I'd invented wasn't being rejected; it was invalidating the entire font block, which then fell back to defaults — loadSystemFonts: true — and every single render was happily using the same host fallback font. My probe had been measuring my Mac's font resolution the whole time, with a straight face.
One look at the actual typings would have caught it earlier:
font?: {
loadSystemFonts?: boolean
fontFiles?: string[] // paths — this is the API
fontDirs?: string[]
...
}
@resvg/resvg-js 2.6.2 has no fontBuffers. And unknown keys in that block don't throw — the binding silently drops the block and proceeds with defaults. A typo doesn't fail; it succeeds wrongly, which is the worst possible failure mode for a measurement.
What's actually true, measured properly
With valid options, the picture is completely different — and better than Plan A ever was:
fontFiles + loadSystemFonts:false:
geist a56fbacc… bric 1fa94acd… → DISTINCT fonts render DISTINCTLY ✓
no fontFiles → identical to blank (no glyphs, no host leak) ✓
@font-face data-URI (valid options): no glyphs — genuinely ignored, same as librsvg
So: neither librsvg nor resvg reads data:-URI @font-face. The embedding plan was never going to work on either renderer. But resvg gives you something strictly stronger — you hand it the font files programmatically and switch host fonts off entirely:
import { Resvg } from '@resvg/resvg-js';
const png = new Resvg(cardSvg, {
font: {
loadSystemFonts: false, // deterministic: host fonts cannot leak in
fontFiles: ['./assets/fonts/bricolage-grotesque-800.ttf'],
},
}).render().asPng();
Vendored TTF in the repo, zero host dependence, byte-reproducible everywhere. That's the portable recipe. (<image href="data:image/png;base64,…"> works on both renderers, verified the same way — so screenshot composition survives a rasterizer swap.)
The embedding helper never shipped. The package README now documents the measured matrix instead, including the footgun — which is worth more than the helper would have been.
What generalizes
A negative result needs a positive control on the option path, not just the feature path. My probe had a control for "does text render at all" — good — but nothing checking that the options I passed were accepted. When a library silently ignores an options block containing an unknown key, your carefully-designed experiment runs with default settings and tells you a confident story about the wrong configuration. Before trusting any "X doesn't work": break something the options should control, and watch it actually break.
"I remember the API" is not an API. Thirty seconds in index.d.ts beats a plausible memory every time. The gap between fontBuffers and fontFiles cost a full wrong conclusion.
Plans written as facts rot into fake facts. My backlog said portable extraction "means embedding an @font-face TTF in the SVG first" — a hypothesis wearing a plan's clothes. Nobody had ever measured it. The extraction work is what finally forced the question, and the answer was no, and here's what to do instead — which is exactly what you want a measurement to hand you before the code ships, not after.