AstroとSatoriで記事ごとのOGP画像を生成する — 日本語・中国語フォントとXのタイトル表示
AstroでSatoriとresvgを使い、記事と言語ごとに1200x630のOGP画像をビルド時に生成します。日本語と中国語のフォントを正しく表示し、文字をXのタイトル表示と重ねない方法です。

目次
はじめに
X で共有された Qiita の記事を見かけました。そのリンクカードには、Qiita のロゴと記事タイトルが画像の中に描かれていました。一方、このブログのリンクに表示されるのはカバー画像だけで、少し物足りなく見えました。
このブログのカバー画像は、トピックごとに共有するレジストリから選んでいます。そのため Astro の記事は、どれを共有しても同じ黒い Astro ロゴのカードになっていました。このレジストリを作ったとき、私はカバー画像をそのまま OGP 画像に使えると判断し、Satori を使うつもりでいたが不要だったと書いています。
今では、すべての記事が3言語それぞれに専用の1200×630のカードを持ち、ビルド時に Satori と resvg で描画されます。カードには記事タイトル、サイトの印、そしてトピックのカバー画像を小さなタイルとして載せています。共有したリンクを見ればどの記事かがわかり、タイトルやカバー画像を変えれば次のビルドでカードも描き直されます。

他のサイトは og:image に何を入れているか
og:image は、リンクが共有されたときにクローラーが表示する画像を指定する Open Graph のメタタグです。記事に専用の画像がないことが多いサイトはタイトル入りのカードを自動で生成し、記事ごとにカバー画像があるサイトはそのカバー画像を使います。私は Claude Code に、他のサイトがどう決めているかを尋ねました。
次のサイトはカードを自動で生成していますが、やり方はそれぞれ違います。
GitHub は、リポジトリにカスタム画像をアップロードする人がほとんどいなかったため、この用途のサービスを作りました。リポジトリ名、説明、オーナーのアバター、各種の数を HTML テンプレートに埋め込み、Puppeteer でスクリーンショットを撮ります。

Zenn のカードには、タイトル、著者のアバターと名前、Zenn のロゴが入ります。og:image は Cloudinary の URL で、そのパラメーターがタイトルをテキストレイヤーとして固定の背景に重ねます。

Qiita のカードは、タイトルの上にロゴ、下に著者のアバターとユーザー名を置きます。og:image は imgix の URL で、背景、アバター、タイトルの文字を同じように合成します。

Zenn と Qiita は、カードの描画を外部の画像処理サービスに任せています。Zenn は Cloudinary、Qiita は imgix です。こうしたサービスは URL のパラメーターに従ってその場で画像を合成し、自前の CDN から配信します。
たとえば記事ごとにカバー画像がある note では、著者が記事にアップロードした見出し画像がそのまま OGP 画像になります。
このブログがカバー画像を共有している理由
このブログの記事には専用の画像がないのに、note と同じようにカバー画像をそのまま OGP 画像にしていました。
このブログを作るとき、記事ごとに専用の画像は作らず、同じトピックの記事で1枚のカバー画像を共有することにしました。記事一覧でそれぞれの記事のトピックがひと目でわかるようにしたかったのが1つ、記事ごとにカバーのテイストがばらばらだと雑然として見えるのがもう1つの理由です。
ただ、SNS で共有したときには、添付される OGP 画像に見分けがつかず、私のブログのどの記事なのかがわかりにくくなります。同じ pnpm の記事を Slack で展開したときの変更前と変更後です。


Slack は画像の上に og:title も表示するため、Slack ではタイトルが2回出ることになりますが、これは許容範囲だと考えています。
カードの構成:タイトル、印、タイルにしたカバー画像
カードでは、カバー画像を右上の小さなタイルとして残しています。どの記事にもすでにカバー画像があるので、タイトルとカバーのタイルを同じカードに載せました。
カバー画像はどれも1200×630の横長のロゴカードです。左側にはすでにブログのロゴがあるので、カバー画像全体を320×168に縮小し、トピックを示すバッジとして右側に置きました。
残りのレイアウトは、X が自前の文字を置く位置で決まります。大きなリンクカードでは、X が og:title をカード下部に重ねて表示します。冒頭の X のスクリーンショットにある暗い帯がそれです。そのため カード上の要素は、下端5分の1の上端である504pxより下にはみ出してはいけません。タイトルは、タイルとこの線のあいだの領域を使います。
このブログの記事タイトルはおおむね Main — subtitle の形です。カードはダッシュの位置でタイトルを分けます。本題は太字で大きく、80pxから48pxのあいだで組み、サブタイトルは30pxの落ち着いたグレーで組みます。上端の12pxの朱色のバーは、ブログのロゴにある印の色に合わせています。

言語ごとにプリレンダリングするエンドポイント
各カードは、pnpm build のあいだに Astro のエンドポイントが書き出す静的ファイルです。エンドポイントは .png.ts のルートです。Astro はビルド時にその GET を呼び出し、返ってきた PNG を静的ファイルとして保存します。X などのサイトがカードを取りに来たときに受け取るのは、このファイルです。カードのルートと記事ページは、同じ関数 getArticlePaths から記事の一覧を受け取ります。そのため、公開済みの記事にはどれもちょうど1枚のカードがあり、下書きにはどちらもありません。
各言語には1行のラッパーを置きます。
import type { APIContext } from "astro";import { ogCardPaths, ogCardResponse } from "@/lib/og-card";
export const getStaticPaths = () => ogCardPaths("ja");export const GET = (context: APIContext) => ogCardResponse("ja", context);カードは src/lib/og-card.ts というモジュールが、2段階で描画します。
- Satori でレイアウトする:Satori は Vercel がオープンソースで公開しているライブラリで、JSX と CSS で記述したレイアウトを SVG に変換します。ここでは React は使わず、レイアウトを
{ type, props }のプレーンなオブジェクトとして直接書いて Satori に渡します。 - resvg で PNG にする:
@resvg/resvg-jsが Satori の出力した SVG を PNG にラスタライズします。X のog:imageは JPG、PNG、WEBP、GIF には対応していますが、SVG には対応していないためです。
const tree = el("div", { width: 1200, height: 630, display: "flex", flexDirection: "column", padding: 64 }, [ el("div", { display: "flex", justifyContent: "space-between" }, [ sealAndSiteName, { type: "img", props: { src: tileDataUri, width: 320, height: 168 } }, ]), el("div", { fontSize: size, fontWeight: 700, lineClamp: maxLines }, title), el("div", { fontSize: 30, color: "#475569", lineClamp: 2 }, subtitle),]);const svg = await satori(tree, { width: 1200, height: 630, fonts });const png = new Resvg(svg).render().asPng();Satori、resvg、フォントはどれも devDependencies で、ビルド時にしか使わないため、サイトが静的ファイルと一緒にデプロイするサーバーレス関数には含まれません。ビルドログでは1枚あたり約25msでした。
og-card.ts は、カード1枚ごとに次の処理を順に行います。
- タイトルを
—の位置で本題とサブタイトルに分ける。 - タイトルの長さと残りの高さから、タイトルの文字サイズを決める。
Intl.Segmenterで日本語と中国語の単語をつなげ、単語の途中で改行されないようにする。- カードの文字に必要なフォントのサブセットだけを読み込み、ファイルごとに別の名前を付ける。
sharpでカバー画像をタイルに縮め、タイトルや印と一緒に{ type, props }のレイアウトを組む。- レイアウトを Satori に渡して SVG にし、resvg で PNG にする。
async function renderOgCard(entry: BlogEntry, locale: Locale): Promise<Buffer> { const { title, thumbnail } = entry.data; // 1. 「本題 — サブタイトル」を分ける const [rawMain, ...rest] = title.split(/\s+—\s+/); const rawSub = rest.length ? rest.join(" — ") : undefined; // 2. タイトルの文字サイズを決める const { size, maxLines } = fitTitle(rawMain, rawSub, locale); // 3. CJK の単語をつなげる const main = glue(rawMain, locale); const sub = rawSub && glue(rawSub, locale); // 4. 必要なサブセットだけ読み込む const fonts = [...fontsFor(locale, 700, main + brand + "…" + WJ) /* … */]; // 5. カバーのタイルを含めてレイアウトを組む const tree = el("div", { /* … */ }, [/* seal, await thumbnailTile(thumbnail), main, sub */]); // 6. SVG にレイアウトし、PNG にラスタライズする const svg = await satori(tree, { width: W, height: H, fonts }); return new Resvg(svg, { fitTo: { mode: "width", value: W } }).render().asPng();}3番目と4番目の処理が次の2つの落とし穴に対応し、2番目の処理は最後の節で説明します。
カバー画像のタイルは、レジストリのキー(たとえば astro-generic)を使って src/assets/thumbnails/ から <key>.png または <key>.webp を直接読み込みます。読み込んだ画像は sharp で320×168にリサイズして PNG に変換し、Satori に渡します。
最後に、記事レイアウトで og:image の参照先をリサイズしたカバー画像からカードに切り替えます。
const ogAsset = await getImage({ src: cover.src, width: 1200, height: 630, format: "png" });const ogImage = new URL(ogAsset.src, Astro.site).href;const ogImage = new URL(localeUrl(locale, `/og/${slug}.png`), Astro.site).href;3言語のタイトルを Satori で描くときの2つの落とし穴
2つとも、このブログの実際のタイトルで起き、どちらもエラーを出さずに描画されます。
落とし穴1:Satori で OGP 画像に豆腐が出る
Satori はファミリー名ごとにフォントファイルを1つしか読み込みません。そのため、複数のファイルに分割されたフォントは、複数の名前で登録する必要があります。Satori が描画できるのはフォントを渡された文字だけで、ビルド時にはシステムフォントが代わりに使われることもありません。対応するフォントが見つからない文字は空の四角で描かれ、俗に「豆腐」と呼ばれます。
フォントは Fontsource のパッケージ、@fontsource/noto-sans-jp と @fontsource/noto-sans-tc から取りました。Fontsource は Google Fonts などのオープンソースフォントを npm パッケージにしたものなので、リポジトリにフォントのバイナリが入らず、ビルド中に何もダウンロードしなくて済みます。Noto Sans TC を加えたのは、Noto Sans JP に、日本語では別の字形で書くか使わない「查」「夠」のような中国語の字形が含まれていないためです。
Fontsource は各ウェイトを unicode-range(各ファイルが受け持つ文字の範囲)ごとのサブセットに分割していて、Noto Sans JP の Bold だけで約125ファイルあります。それぞれ WOFF と WOFF2 の両方で配布されていますが、Satori が読めるのは TTF、OTF、WOFF で、WOFF2 は読めません。
WOFF ファイルは1ウェイトあたり約5.2MBになり、カードごとにすべて読み込むのは無駄です。そこで og-card.ts は各ウェイトの CSS を読み、範囲がカードの文字を含むサブセットだけを Satori に渡します。
これらのサブセットを共通のファミリー名 Noto Sans JP で登録したところ、次のように描画されました。

各サブセットに別々の名前を付け、すべての名前を fontFamily に並べると直ります。複数のファミリーを並べて指定すれば、Satori はその中で1文字ずつフォールバックするからです。
return faces(pkg, family, weight) .filter(({ ranges }) => cps.some((cp) => ranges.some(([a, b]) => cp >= a && cp <= b))) .map(({ family, file }) => { let data = fileCache.get(file); if (!data) fileCache.set(file, (data = readFileSync(file))); return { name: `${family} ${file}`, weight, style: "normal", data }; });最後に、カードのいちばん外側の要素で、これらの名前をすべて引用符で囲み、カンマ区切りで fontFamily に指定します。
const stack = [...new Set(fonts.map((f) => `"${f.name}"`))].join(", ");// → "Noto Sans JP …/noto-sans-jp-0-700-normal.woff", "Noto Sans JP …/noto-sans-jp-latin-700-normal.woff", …
const tree = el("div", { /* … */ fontFamily: stack }, [/* … */]);落とし穴2:Satori は日本語と中国語の単語をどこででも改行する
Satori は CJK の文字と文字のあいだをすべて改行可能な位置として扱います。日本語や中国語の単語辞書を持っていないためです。前に見た Qiita のカードで「検証」が2行に分かれていたのも、同じ問題です。次の繁体字中国語のタイトルは「五次」(5回)の途中で折り返され、1行目の末尾に 五、2行目の先頭に 次 が来ています。

ブラウザーにも Node.js にも組み込まれている Intl.Segmenter は、granularity: "word" を指定すると、両言語の辞書を使って単語を区切ります。og-card.ts はタイトルを単語に分け、CJK の各単語の文字と文字のあいだに U+2060 WORD JOINER を入れます。WORD JOINER は、その位置での改行を禁止する見えない文字です。
const CJK = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;const WJ = "\u2060";
function glue(text: string, locale: Locale): string { const seg = new Intl.Segmenter(FONT_PKG[locale].segmenter, { granularity: "word" }); return [...seg.segment(text)] .map(({ segment }) => (CJK.test(segment) ? [...segment].join(WJ) : segment)) .join("");}同じタイトルは、今では 是從 / 五次走錯路 と単語の境目で改行されます。WORD JOINER も1つの文字なので、フォントのサブセットを選ぶ対象の文字列に含めておく必要があります。含めないと対応するフォントが見つからず、WORD JOINER 自体が四角として描画されます。
セーフラインより上の高さでタイトルの文字サイズを決める
タイトルの文字サイズは、セーフライン(504px)より上に残った高さから選びます。文字が大きいほどタイトルは多くの行に折り返されるので、収まるかどうかは「行数 × 文字サイズ」の高さの合計で判断します。
タイトル領域は y=272 から始まります。その上に12pxのバー、64pxの余白、168pxのタイル、28pxの間隔があるためで、セーフラインまでは232pxです。まずサブタイトルの分を差し引き、残りに推定の高さが収まるサイズを、80、72、64、56、48pxのうち最大のものから選びます。
function fitTitle(main: string, sub: string | undefined, locale: Locale) { const subH = sub ? SUB_GAP + 2 * SUB_SIZE * SUB_LH : 0; const budget = SAFE_BOTTOM - TEXT_TOP - subH; const widest = longestWord(main, locale); const size = [80, 72, 64, 56, 48].find( (s) => lines(main, s) * s * lineHeight(s) <= budget && widest * s * 0.5 <= LINE_W, ) ?? 48; const maxLines = Math.max(1, Math.floor(budget / (size * lineHeight(size)))); return { size, maxLines };}サブタイトルの分は常に2行分を確保します。サブタイトルの lineClamp(表示する最大行数。超えた分は省略記号で切る)が2だからです。推定より1行多く折り返して2行になっても、そのまま収まります。
タイトル自体にも上限があります。fitTitle は残りの高さに収まる行数 maxLines を求め、タイトルの lineClamp に設定します。そのため 推定が外れても、文字は X のタイトル帯に入らず省略記号で終わります。今このサイトがビルドするカードで最も低い位置にある文字(タイトルかサブタイトル)は y=488 で、セーフラインより上にあります。
widest のチェックは、WORD JOINER の副作用に対処するものです。つなげた単語は途中で改行できないので、長いカタカナの複合語を80pxで組むと右端からはみ出します。サイズは、最も長い単語が1行に収まるまで1段ずつ下がります。
まとめ
このブログのカバー画像はトピックごとに共有しているので、記事の話題はわかっても、どの記事かはわかりません。OGP 画像は SNS で共有するためのもので、カバー画像とはもともと目的が違います。今ではすべての記事に自動生成した OGP 画像があり、カバー画像は右上の小さなタイルになって、タイトルは画像に直接書かれています。共有されたリンクを見れば、どの記事かがすぐにわかります。
ビルド時には、言語ごとにプリレンダリングするエンドポイントがカードのレイアウトを用意し、Satori と resvg に渡して描画します。日本語と中国語のタイトルを正しく表示するには、Satori が自分ではやらないことが2つ必要です。Fontsource のサブセットごとに別のフォント名を付けることと、Intl.Segmenter で WORD JOINER を入れて単語をまとめることです。また、タイトルの文字サイズは X のタイトル帯より上に残った高さに合わせて決めます。



