opopa.top

OG images from design tokens: satori, resvg and three traps

·5 min read·updated

toolsdesign

Build social cards from your design system's colors and fonts, without Chrome in CI or broken Cyrillic. Includes the generator and font scripts.

A Telegram or X link without a preview card looks like spam. You can draw each card by hand, run headless Chrome in CI, or render it from the same tokens as the site: colours and fonts from design.md.

The third option takes 177 lines for the generator and 53 for font preparation. Ten runs on an M-series laptop reported a median of 42 ms per image, with a minimum of 34 and a maximum of 62. The benchmark script is included in the artifact.

How it works

Describe the card as an object tree. satori lays it out using flexbox rules and produces SVG; resvg rasterizes that to PNG. No browser or runtime service: everything happens during the build.

object tree → satori → SVG → resvg → PNG 1200×630

Speed is not the main benefit. The generator starts with constants copied from design.md, defined before layout work. The background uses the same #0b0c0e, and the artifact badge the same #ff8964, as their counterparts on the article page.

Rendering options

Approach Advantage Drawback
Headless Chrome (Puppeteer) Pixel-for-pixel browser rendering A full browser in CI and seconds per image
satori + resvg 42 ms per image in our run, without a browser A subset of CSS and its own font rules
astro-og-canvas Ready-made and configurable Another layout to adapt to your tokens
Hand-made images Full control Does not scale well as the number of posts grows

We expect more posts, and each card should use the design system, so we chose the second option.

The card belongs to the design system

A social card is another output of the design system, like a button. It needs access to the same tokens, rather than a separate design process.

Three traps

1. satori does not accept woff2

It accepts ttf, otf and woff. Modern font packages tend to offer woff2 first, so feeding that file straight to the renderer fails during parsing.

2. Same-name fonts do not provide glyph fallback

Fontsource supplies Latin and Cyrillic in separate files. Pass both under the same name and you might expect satori to select the right file for each glyph. It does not: one file is selected for the name, leaving the other glyphs as NO GLYPH boxes.

A Russian title then turns into a mixture of readable text and boxes where letters or punctuation should be. We fixed this by merging the subsets during font preparation:

from fontTools.merge import Merger
from fontTools.ttLib import TTFont

for name in ["inter-latin-400-normal.woff", "inter-cyrillic-400-normal.woff"]:
    font = TTFont(src / name)
    font.flavor = None          # woff -> ttf
    font.save(tmp / name.replace(".woff", ".ttf"))

Merger().merge(ttfs).save("src/assets/fonts/og-body.ttf")

The resulting font file contains both alphabets, without missing-glyph boxes.

3. Native resvg-js caused bundling failures

@resvg/resvg-js uses a native .node binding. In our Vite build it failed with:

[UNLOADABLE_DEPENDENCY] Could not load @resvg/resvg-js-darwin-arm64/resvgjs.darwin-arm64.node
    stream did not contain valid UTF-8

Neither ssr.external nor optimizeDeps.exclude resolved that build path: the module was read while static routes were being generated. We switched to @resvg/resvg-wasm: the same API, initialized once per process, without the native-binding bundling problem.

let wasmReady: Promise<void> | null = null;

function ensureWasm(): Promise<void> {
  if (!wasmReady) {
    const wasm = fs.readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm');
    wasmReady = initWasm(wasm);
  }
  return wasmReady;
}

With the WASM renderer, on-demand edge rendering is an option. Our current wrapper reads files in Node during the build.

Picking a font size without measuring text

satori does not expose a text-width measurement for this step. We use a step function based on title length instead:

function titleSize(title: string): number {
  if (title.length <= 40) return 66;
  if (title.length <= 65) return 54;
  if (title.length <= 90) return 44;
  return 38;
}

It is approximate but deterministic and avoids another rendering pass. The content schema caps titles at 120 characters; the finished card still needs a visual check for clipping.

The result

External image generation is needed only for design concepts, before layout. OG images themselves are built locally, without a network call.

src/pages/og/[id].png.ts produces an image for each published Russian post; src/pages/og/default.png.ts supplies the home and utility-page card. English versions use the matching routes under /en/og/. All are prerendered static files served with a year-long cache policy.

Always look at the result. Our first build looked successful in the logs while half the title was rendered as NO GLYPH boxes.