サイトを共有したときに表示される 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 のための設定を足さずに済むこの形を選んだ。
小さなヘルパを一つ用意して、要素を組み立てる。
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' を明示する。
el('div', { display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: '84px 90px' }, [ kicker, // 上:ラベルと文脈 title, // 中:大きな見出し footer, // 下:区切り線とフッター])ビルド時に PNG を返すエンドポイント
画像はリクエストのたびに作るのではなく、ビルド時にすべて生成して静的ファイルにする。
Astro のエンドポイント src/pages/og/[...slug].png.ts に getStaticPaths を置き、記事と本(索引と章)を列挙する。
// 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 一つに集約し、生成側もページ側もそれを通す。
// ('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 を組にした。
el('div', { display: 'block', // flex のままだとクランプが効かない fontSize: 62, lineHeight: 1.34, maxWidth: 1010, lineClamp: 3,}, title)行末で消える空白
フッターの右下に by txxxxxxxxxxo と署名を置いた。
by と名前を別の要素にして横に並べたところ、間の空白が消えて bytxxxxxxxxxxo とくっついた。
flex の行では、要素の末尾にある空白が落とされるためである。
空白を文字で持たせるより、間隔として持たせるほうが確実だった。 不可視の空白を挟む手もあるが、後から余計な文字として消されやすい。
el('div', { display: 'flex' }, [ el('span', { marginRight: 8 }, 'by'), el('span', { color: PRIMARY }, 'txxxxxxxxxxo'),])背景のドットの敷き方
背景のドットグリッドは、CSS では繰り返しの radial-gradient で作っていた。
satori にも同じ指定を渡したが、繰り返しの背景は思うようには出なかった。
一枚の完成した SVG を、背景として敷くことにした。
ドットのパターンと、周囲を淡く落とすヴィネットを SVG の中で描き、データ URI にして最背面の img に置く。
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 は読めない。
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() を起点にできる。
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 なので、配信時にネイティブコードは動かない。
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 に固め、フォントは明示して渡す。 この構えでいれば、ブラウザとの差分に振り回されずにカードを作れる。