← Posts

satori で日本語の OG 画像をビルド時に生成する

サイトを共有したときに表示される OG 画像をページごとに生成するよう実装した。

生成には satori1 を使った。 HTML と CSS の部分集合を受け取り、SVG を返すライブラリである。 その SVG を resvg2 で PNG に変換し、静的ファイルとして書き出す。

satori は「HTML と CSS で書ける」とうたっているが、ブラウザの CSS とは挙動が違う箇所が多い。 この記事は、実装しながらつまずいた箇所を、作る順に並べたものである。

出力

完成イメージ

記事用は左上に POST のラベル3を置き、その下にタイトルを大きく置く。

背景はサイトと同じドットグリッドで、フォントは IBM Plex4 でそろえる。

これらを固定テンプレートの範囲で作るのは難しかった。 astro-og-canvas5 のような既製の手段は、背景が単色かグラデーションで、要素の並びも決まっている。 ドットグリッドと二段組みのフッターを再現するには、レイアウトを自分で組める satori のほうが向いていた。

satori に渡す要素ツリー

satori は JSX を受け取るが、JSX でなくても書ける。 { type, props } の形をしたプレーンなオブジェクトを渡せばよい。 画像の生成はビルド時のエンドポイントで動くので、JSX のための設定を足さずに済むこの形を選んだ。

小さなヘルパを一つ用意して、要素を組み立てる。

src/lib/og/card.ts
type Node = any;
const el = (type: string, style: Record<string, unknown>, children?: Node): Node => ({
type,
props: children === undefined ? { style } : { style, children },
});

ここで一つ注意がいる。 satori では div の既定の表示が flex になっている。 CSS の div は block なので、同じつもりで子要素を積むと横に並ぶ。 縦に積みたいところでは flexDirection: 'column' を明示する。

src/lib/og/card.ts
el('div', { display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '84px 90px' }, [
kicker, // 上:ラベルと文脈
title, // 中:大きな見出し
footer, // 下:区切り線とフッター
])

ビルド時に PNG を返すエンドポイント

画像はリクエストのたびに作るのではなく、ビルド時にすべて生成して静的ファイルにする。 Astro のエンドポイント src/pages/og/[...slug].png.tsgetStaticPaths を置き、記事と本(索引と章)を列挙する。

src/pages/og/[...slug].png.ts
// slug = 各ページの URL から先頭のスラッシュを外したもの
const ogParam = (locale: Locale, subpath: string) => withLocale(locale, subpath).slice(1);
export async function getStaticPaths() {
const paths = [];
for (const entry of await getCollection('post')) {
const { slug, locale } = postKey(entry.id);
paths.push({
params: { slug: ogParam(locale, `/post/${slug}`) },
props: { tag: 'POST', title: entry.data.title },
});
}
return paths;
}
export const GET: APIRoute = async ({ props }) => {
const png = await renderCard(props as CardSpec);
return new Response(png, { headers: { 'Content-Type': 'image/png' } });
};

生成した画像は、各ページの og:image から指す。 このときパスの作り方をページ側と生成側の二か所に書くと、指す URL と実際に生成されるファイルがずれて、共有時に画像が 404 になる。 パスの規約は withLocale 一つに集約し、生成側もページ側もそれを通す。

src/lib/i18n.ts
// ('ja', '/post/x') -> '/og/post/x.png' 、('en', '/post/x') -> '/og/en/post/x.png'
export function ogImageHref(locale: Locale, subpath: string): string {
return `/og${withLocale(locale, subpath)}.png`;
}

ここまでで、英語のタイトルならカードが一枚出るようになる。 日本語とレイアウトの調整は、この先で詰める。

ブラウザの CSS と違うところ

satori は CSS の部分集合しか解釈しない。 CSS のつもりで書くと効かない指定が、いくつかあった。

タイトルを 3 行に収める

長いタイトルは 3 行で切り、あふれた分を省略記号にしたい。 CSS では -webkit-line-clamp を使うところである。 最初は display: 'flex' のまま lineClamp: 3 を指定したが、クランプは効かず、4 行目が下の区切り線に重なった。

satori のクランプは、指定の組み合わせが合ったときだけ働く。 display: 'block'lineClamp を組にするか、display: '-webkit-box'WebkitBoxOrient: 'vertical'WebkitLineClamp をそろえるか、そのどちらかである。 display: 'flex' のままでは、どちらの条件にも当てはまらない。

見出しは block にして lineClamp を組にした。

src/lib/og/card.ts
el('div', {
display: 'block', // flex のままだとクランプが効かない
fontSize: 62,
lineHeight: 1.34,
maxWidth: 1010,
lineClamp: 3,
}, title)

行末で消える空白

フッターの右下に by txxxxxxxxxxo と署名を置いた。 by と名前を別の要素にして横に並べたところ、間の空白が消えて bytxxxxxxxxxxo とくっついた。 flex の行では、要素の末尾にある空白が落とされるためである。

空白を文字で持たせるより、間隔として持たせるほうが確実だった。 不可視の空白を挟む手もあるが、後から余計な文字として消されやすい。

src/lib/og/card.ts
el('div', { display: 'flex' }, [
el('span', { marginRight: 8 }, 'by'),
el('span', { color: PRIMARY }, 'txxxxxxxxxxo'),
])

背景のドットの敷き方

背景のドットグリッドは、CSS では繰り返しの radial-gradient で作っていた。 satori にも同じ指定を渡したが、繰り返しの背景は思うようには出なかった。

一枚の完成した SVG を、背景として敷くことにした。 ドットのパターンと、周囲を淡く落とすヴィネットを SVG の中で描き、データ URI にして最背面の img に置く。

src/lib/og/card.ts
const dotSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630">
<defs>
<pattern id="d" width="40" height="40" patternUnits="userSpaceOnUse">
<circle cx="0" cy="0" r="1.4" fill="#d4cfc2"/>
<circle cx="20" cy="20" r="1.4" fill="#d4cfc2"/>
</pattern>
<radialGradient id="v" cx="50%" cy="40%" r="72%">
<stop offset="52%" stop-color="#FBFBFA" stop-opacity="0"/>
<stop offset="100%" stop-color="#FBFBFA" stop-opacity="1"/>
</radialGradient>
</defs>
<rect width="1200" height="630" fill="url(#d)"/>
<rect width="1200" height="630" fill="url(#v)"/>
</svg>`;
const dotUri = `data:image/svg+xml;base64,${Buffer.from(dotSvg).toString('base64')}`;

繰り返しやグラデーションのように satori 側の解釈に幅がある指定は、あらかじめ SVG に固めてしまうと安定した。

日本語フォントの扱い

日本語を描くには、その字を含むフォントを satori に渡す必要がある。 CJK でいちばん手間がかかったのは、ここだった。

フォントの読み込みと対応形式

フォントは fonts に名前と重みを添えて渡す。 形式は TrueType か OpenType で、woff2 は読めない。

src/lib/og/card.ts
const fonts = [
{ name: 'JP', data: font('IBMPlexSansJP-SemiBold.otf'), weight: 600, style: 'normal' },
{ name: 'Sans', data: font('IBMPlexSans-Regular.otf'), weight: 400, style: 'normal' },
{ name: 'Mono', data: font('IBMPlexMono-Medium.otf'), weight: 500, style: 'normal' },
{ name: 'Mono', data: font('IBMPlexMono-Regular.otf'), weight: 400, style: 'normal' },
];

日本語見出しに使う IBM Plex Sans JP は、ラテン文字も同じ書体で持っている。 そのため、見出しのラテン部分のために別の書体を足す必要はない。 ある字が指定した書体になければ、satori は fonts の並び順で次の書体を探すので、この一本で日本語とラテンの見出しがまかなえる。

フォントの場所とビルドのパス解決

フォントはリポジトリに同梱し、ビルド時にファイルとして読む。 最初はモジュールからの相対で読もうとして、new URL('./fonts/...', import.meta.url) を使った。 これはビルドで失敗した。

原因は、Astro がこのエンドポイントを dist の下にまとめて出力することだった。 import.meta.url は出力後のモジュール位置を指し、その隣に同梱フォントは無い。 ENOENT でフォントが見つからず、ビルドが止まる。

プロジェクト直下を起点にして、ソースツリーから読むと解決した。 astro build は常にプロジェクト直下で動くので、process.cwd() を起点にできる。

src/lib/og/card.ts
const FONT_DIR = join(process.cwd(), 'src/lib/og/fonts');
const font = (name: string) => readFileSync(join(FONT_DIR, name));

絵文字と対応外の文字

同梱したのは IBM Plex の日本語とラテン、それに等幅だけである。 この範囲に無い字は、その字の位置に空の箱(いわゆる豆腐)が描かれる。 絵文字や、ハングルのように別の文字体系の字がこれにあたる。

やっかいなのは、この豆腐が無言で出ることである。 satori は例外も警告も出さず、ビルドは通り、壊れたカードだけが出荷される。 タイトルは frontmatter からそのまま描画に渡るので、書いた字がそのまま結果に出る。

対処には、絵文字用のフォントを足して描く、描く前に対応外の字を落とす、そのまま割り切る、の三つがある。 このサイトのタイトルは日本語と英語が中心で、絵文字はまず入らない。 カードの見た目を IBM Plex だけで保ちたいこともあり、割り切ってこの制約を受け入れた。

オフラインで決定論的にビルドする

このパイプラインは、ビルド時に外部へ問い合わせない。 フォントを同梱したので、その取得も含めて、環境変数もネットワークも要らない。 同じ入力なら、同じ画像が出る。

PNG への変換には resvg のネイティブモジュールを使う。 ネイティブと聞くと配信先の実行環境が気になるが、resvg が動くのはビルドのときだけである。 生成物は静的な PNG なので、配信時にネイティブコードは動かない。

src/lib/og/card.ts
const svg = await satori(tree(spec), { width: 1200, height: 630, fonts });
return new Resvg(svg, { fitTo: { mode: 'width', value: 1200 } }).render().asPng();

移植で気をつけること

satori でつまずいた箇所は、たどればどれも同じところに返ってくる。 「HTML と CSS で書ける」が、動くのはその部分集合だけ、という一点である。

素朴な HTML と CSS の範囲で組み、解釈に幅が出るところは SVG に固め、フォントは明示して渡す。 この構えでいれば、ブラウザとの差分に振り回されずにカードを作れる。

Footnotes

  1. satori は Vercel の OSS で、@vercel/og の描画エンジンにあたる。

  2. resvg は Rust 製の SVG レンダラで、@resvg/resvg-js はその Node バインディング。

  3. いつか記事以外のコンテンツが追加されたら、このラベルで識別する。

  4. IBM Plex は IBM の OSS 書体で、IBM/plex にラテン・日本語・等幅などがそろう。

  5. astro-og-canvas は CanvasKit を使う Astro 向けの OG 画像生成ライブラリ。テンプレートは固定だが、設定だけで動く。