OG-картинки на своих токенах: satori, resvg и три грабли
Как сделать генератор соцкарточек, который берёт цвета и шрифты из дизайн-системы, не тащит Chrome в CI и не падает на кириллице. С рабочим кодом.
Ссылка на пост в Telegram или X без карточки выглядит как спам. Карточку можно рисовать руками
на каждый пост, можно поднимать headless Chrome в CI, а можно отрендерить её из тех же токенов,
из которых сделан сайт: цвета и шрифты из design.md.
Третий путь — 177 строк генератора и 53 строки на подготовку шрифтов. Десять прогонов на M-ноутбуке дали медиану 42 мс на картинку (мин 34, макс 62; скрипт замера — в артефакте).
Как это устроено
Описываем карточку деревом объектов, satori раскладывает его по правилам flexbox
в SVG, resvg растеризует в PNG. Ни браузера, ни рантайма — всё на этапе сборки.
дерево объектов → satori → SVG → resvg → PNG 1200×630
Главная ценность не в скорости. Файл генератора начинается с констант, скопированных
из design.md — того самого, что задаётся до вёрстки:
тот же #0b0c0e под фоном, тот же #ff8964 на плашке «Артефакт», что и в блоке артефакта
на странице поста.
Чем рендерить
| Способ | Плюс | Минус |
|---|---|---|
| Headless Chrome (Puppeteer) | Пиксель-в-пиксель как в браузере | Полный браузер в CI, секунды на картинку |
satori + resvg |
42 мс на картинку, без браузера | Подмножество CSS, свои правила по шрифтам |
astro-og-canvas |
Готовое, настраивается конфигом | Чужая вёрстка, свои токены встраивать неудобно |
| Ручные картинки | Полный контроль | Плохо масштабируется с ростом числа постов |
Постов будет много, карточка должна повторять дизайн-систему — берём второй вариант.
Карточка — часть дизайн-системы
Соцкарточка — такой же выход дизайн-системы, как кнопка. Ей не нужен отдельный дизайнер, ей нужен доступ к тем же токенам.
Три грабли
1. satori не умеет woff2
Принимает ttf, otf и woff. Современные пакеты шрифтов отдают в первую очередь woff2 —
первый же наивный readFileSync падает на парсинге.
2. Одноимённые шрифты не фолбэчат по глифам
Fontsource отдаёт кириллицу и латиницу разными файлами. Передаёшь оба под одним именем
и ждёшь, что satori выберет нужный. Он не выберет: для имени берётся один шрифт, всё
остальное рисуется чёрными плашками NO GLYPH.
Заголовок «LLM на практике: суть, тонкости» превращается в три плашки, русский текст и ещё три плашки вместо двоеточия и запятых.
Лечится слиянием на этапе подготовки:
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")
В итоговом файле — оба алфавита, без плашек отсутствующих глифов.
3. Нативный resvg-js не бандлится
@resvg/resvg-js использует нативный .node-биндинг. В нашем билде Vite
падал так:
[UNLOADABLE_DEPENDENCY] Could not load @resvg/resvg-js-darwin-arm64/resvgjs.darwin-arm64.node
stream did not contain valid UTF-8
Ни ssr.external, ни optimizeDeps.exclude не спасают: модуль читается на этапе сборки
статических маршрутов. Решение — @resvg/resvg-wasm: тот же API, инициализация один раз
на процесс, бандлится без нареканий.
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;
}
С WASM-вариантом можно вынести рендер на лету в edge-окружение. Наша текущая обвязка читает файлы в Node на этапе сборки.
Кегль без измерения текста
satori не даёт померить ширину строки заранее. Вместо этого ступенчатая функция
по длине заголовка:
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;
}
Грубо, зато детерминировано и без лишнего прохода рендера. Схема ограничивает title
120 символами; готовую карточку всё равно нужно проверить на обрезанный текст.
Что в итоге
Внешняя генерация изображений нужна только для концептов дизайна, до вёрстки, — сам OG собирается локально, без сети.
Маршрут src/pages/og/[id].png.ts отдаёт по картинке на каждый опубликованный RU-пост,
src/pages/og/default.png.ts — карточку для главной и служебных страниц.
Для EN есть соответствующие маршруты под /en/og/. Всё пререндерится в статику,
поэтому в рантайме это обычные файлы с годовым кешем.
Проверять результат обязательно глазами: первая сборка выглядела корректно в логах
и при этом показывала NO GLYPH вместо половины заголовка.