opopa.top

OG-картинки на своих токенах: satori, resvg и три грабли

·4 мин чтения·обновлено

инструментыдизайн

Как сделать генератор соцкарточек, который берёт цвета и шрифты из дизайн-системы, не тащит 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 вместо половины заголовка.