AstroとSatoriで記事ごとのOGP画像を生成する — 日本語・中国語フォントとXのタイトル表示

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

白いカードに、青い地球儀を収めた濃いグレーの六角形の両脇に丸い節点が付いたOpen Graphプロトコルのロゴと、濃いグレーのOpen Graphという文字
目次

はじめに

X で共有された Qiita の記事を見かけました。そのリンクカードには、Qiita のロゴと記事タイトルが画像の中に描かれていました。一方、このブログのリンクに表示されるのはカバー画像だけで、少し物足りなく見えました。

このブログのカバー画像は、トピックごとに共有するレジストリから選んでいます。そのため Astro の記事は、どれを共有しても同じ黒い Astro ロゴのカードになっていました。このレジストリを作ったとき、私はカバー画像をそのまま OGP 画像に使えると判断し、Satori を使うつもりでいたが不要だったと書いています。

今では、すべての記事が3言語それぞれに専用の1200×630のカードを持ち、ビルド時に Satori と resvg で描画されます。カードには記事タイトル、サイトの印、そしてトピックのカバー画像を小さなタイルとして載せています。共有したリンクを見ればどの記事かがわかり、タイトルやカバー画像を変えれば次のビルドでカードも描き直されます。

X に投稿された、このブログの記事へのリンク。カードには印とサイト名、タイルにした Astro のカバー画像、大きな文字の記事タイトルとサブタイトルがあり、X の暗いタイトル帯がカード下部の空いた部分に重なっている

X に表示された完成版のカード。X 自身のタイトル帯は、カードが空けておいた領域に収まっています。

他のサイトは og:image に何を入れているか

og:image は、リンクが共有されたときにクローラーが表示する画像を指定する Open Graph のメタタグです。記事に専用の画像がないことが多いサイトはタイトル入りのカードを自動で生成し、記事ごとにカバー画像があるサイトはそのカバー画像を使います。私は Claude Code に、他のサイトがどう決めているかを尋ねました。

次のサイトはカードを自動で生成していますが、やり方はそれぞれ違います。

GitHub は、リポジトリにカスタム画像をアップロードする人がほとんどいなかったため、この用途のサービスを作りました。リポジトリ名、説明、オーナーのアバター、各種の数を HTML テンプレートに埋め込み、Puppeteer でスクリーンショットを撮ります。

リポジトリ oharu121/cli-ascii-logo の GitHub のカード。大きな文字のリポジトリ名、説明、オーナーのアバター、コントリビューター・Issue・スター・フォークの数がある

私のリポジトリの1つの、GitHub のカードです。

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

日本語の記事の Zenn のカード。青から紫のグラデーションの上の白いカードにタイトルがあり、左下に著者のアバターと名前、右下に Zenn のロゴがある

私が Zenn に投稿した記事のカードです。

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

同じ日本語記事の Qiita のカード。上に Qiita のロゴ、3行に折り返したタイトルの3行目は 証 だけで、その下に著者のアバターとユーザー名がある

同じ記事の Qiita のカード。「検証」が2行に分かれています。

Zenn と Qiita は、カードの描画を外部の画像処理サービスに任せています。Zenn は Cloudinary、Qiita は imgix です。こうしたサービスは URL のパラメーターに従ってその場で画像を合成し、自前の CDN から配信します。

たとえば記事ごとにカバー画像がある note では、著者が記事にアップロードした見出し画像がそのまま OGP 画像になります。

このブログがカバー画像を共有している理由

このブログの記事には専用の画像がないのに、note と同じようにカバー画像をそのまま OGP 画像にしていました。

このブログを作るとき、記事ごとに専用の画像は作らず、同じトピックの記事で1枚のカバー画像を共有することにしました。記事一覧でそれぞれの記事のトピックがひと目でわかるようにしたかったのが1つ、記事ごとにカバーのテイストがばらばらだと雑然として見えるのがもう1つの理由です。

ただ、SNS で共有したときには、添付される OGP 画像に見分けがつかず、私のブログのどの記事なのかがわかりにくくなります。同じ pnpm の記事を Slack で展開したときの変更前と変更後です。

pnpm 12 の記事の Slack リンクプレビュー。タイトルと説明文がテキストで表示され、その下に共有の pnpm ロゴのカバー画像がある

変更前。画像は pnpm のカバー画像で、サイト内の pnpm の記事はすべてこれを使っています。

生成したカードに変わった同じ Slack プレビュー。印とサイト名、小さなタイルにした pnpm のカバー画像、大きな文字の記事タイトルとサブタイトルがある

変更後。生成したカードにタイトルが入っているので、画像だけでどの記事かがわかります。

Slack は画像の上に og:title も表示するため、Slack ではタイトルが2回出ることになりますが、これは許容範囲だと考えています。

カードの構成:タイトル、印、タイルにしたカバー画像

カードでは、カバー画像を右上の小さなタイルとして残しています。どの記事にもすでにカバー画像があるので、タイトルとカバーのタイルを同じカードに載せました。

カバー画像はどれも1200×630の横長のロゴカードです。左側にはすでにブログのロゴがあるので、カバー画像全体を320×168に縮小し、トピックを示すバッジとして右側に置きました。

生成した OGP 画像の構成1200×630 のカードを縮尺どおりに描いた図。上端に細い朱色のバーがあります。左上に印とサイト名、右上に 320×168 のカバー画像タイルがあります。その下、y=272 からタイトル領域が始まり、続くサブタイトル領域と合わせて、y=504 の破線のセーフラインまでの 232px を使います。その線より下の下端 5 分の 1 は網掛けで、X が og:title を表示する領域です。12px のバー桜oharu tech blogカバー画像タイル320×168タイトル 80〜48pxサブタイトル 30px2 行分を確保セーフライン y=504X が og:title を重ねる領域文字に使える高さ 232px
タイトル領域には、タイルから X のタイトル帯が始まる線までの高さがそのまま割り当てられます。

残りのレイアウトは、X が自前の文字を置く位置で決まります。大きなリンクカードでは、X が og:title をカード下部に重ねて表示します。冒頭の X のスクリーンショットにある暗い帯がそれです。そのため カード上の要素は、下端5分の1の上端である504pxより下にはみ出してはいけません。タイトルは、タイルとこの線のあいだの領域を使います。

このブログの記事タイトルはおおむね Main — subtitle の形です。カードはダッシュの位置でタイトルを分けます。本題は太字で大きく、80pxから48pxのあいだで組み、サブタイトルは30pxの落ち着いたグレーで組みます。上端の12pxの朱色のバーは、ブログのロゴにある印の色に合わせています。

生成したカード3枚を縦に並べたもの。Astro のタイルが付いたリンクアイコンについての英語タイトル、LLM のタイルが付いた GROUP BY ROLLUP についての日本語タイトル、LLM のタイルが付いた Claude Code のロゴ作成スキルについての繁体字中国語タイトル

言語ごとに1枚ずつ。繁体字中国語のカードは Noto Sans TC で、残りの2枚はラテン文字も収録している Noto Sans JP で組んでいます。

言語ごとにプリレンダリングするエンドポイント

各カードは、pnpm build のあいだに Astro のエンドポイントが書き出す静的ファイルです。エンドポイントは .png.ts のルートです。Astro はビルド時にその GET を呼び出し、返ってきた PNG を静的ファイルとして保存します。X などのサイトがカードを取りに来たときに受け取るのは、このファイルです。カードのルートと記事ページは、同じ関数 getArticlePaths から記事の一覧を受け取ります。そのため、公開済みの記事にはどれもちょうど1枚のカードがあり、下書きにはどちらもありません。

1つの記事一覧からページとカードの両方を作る左の getArticlePaths(locale) が、1言語分の公開済みの全記事を返します。そこから2本の矢印が出ていて、1本は記事ページを HTML で出力するルート blog/[slug].astro へ、もう1本は OGP 画像を PNG で出力するルート og/[slug].png.ts へ向かいます。下の帯には、en・ja・zh-tw でそれぞれ1回ずつ同じ処理を行うと書かれています。getArticlePaths(locale)1言語分の公開済みの全記事blog/[slug].astroog/[slug].png.ts記事ページHTMLOGP 画像PNGen・ja・zh-tw でそれぞれ1回ずつ同じ処理を行う
1つの記事一覧が両方のルートに渡されます。言語ごとに1回ずつです。

各言語には1行のラッパーを置きます。

src/pages/ja/og/[slug].png.ts
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段階で描画します。

  1. Satori でレイアウトする:Satori は Vercel がオープンソースで公開しているライブラリで、JSX と CSS で記述したレイアウトを SVG に変換します。ここでは React は使わず、レイアウトを { type, props } のプレーンなオブジェクトとして直接書いて Satori に渡します。
  2. resvg で PNG にする:@resvg/resvg-js が Satori の出力した SVG を PNG にラスタライズします。X の og:image は JPG、PNG、WEBP、GIF には対応していますが、SVG には対応していないためです。
src/lib/og-card.ts
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枚ごとに次の処理を順に行います。

  1. タイトルを — の位置で本題とサブタイトルに分ける。
  2. タイトルの長さと残りの高さから、タイトルの文字サイズを決める。
  3. Intl.Segmenter で日本語と中国語の単語をつなげ、単語の途中で改行されないようにする。
  4. カードの文字に必要なフォントのサブセットだけを読み込み、ファイルごとに別の名前を付ける。
  5. sharp でカバー画像をタイルに縮め、タイトルや印と一緒に { type, props } のレイアウトを組む。
  6. レイアウトを Satori に渡して SVG にし、resvg で PNG にする。
src/lib/og-card.ts
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 の参照先をリサイズしたカバー画像からカードに切り替えます。

src/layouts/ArticleLayout.astro
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 に渡します。

Fontsource のサブセットから Satori に渡るフォントまで左では、Fontsource が Noto Sans JP Bold を unicode-range ごとに約125のサブセットファイルに分けています。ラテン文字、かな、漢字の2つのブロックなどです。各ファイルには WOFF と WOFF2 があり、WOFF2 には取り消し線が引かれています。中央では、og-card.ts がカードの文字を含むサブセットだけを残し、ファイルごとに別のファミリー名を付けます。右では、Satori が約125ファイルのうち名前の付いた3つを受け取ります。タイトルに不要な漢字(その1)は薄く表示されています。FontsourceNoto Sans JP Boldラテン文字WOFF2WOFFかなWOFF2WOFF漢字(その1)WOFF2WOFF漢字(その2)WOFF2WOFF…約125ファイルog-card.ts1. カードの文字を含むサブセットだけ残す2. ファイルごとに別のファミリー名を付けるSatoriNoto Sans JP · ラテン文字Noto Sans JP · かなNoto Sans JP · 漢字2約125ファイル中3つWOFF2 は使わない:Satori が読めるのは TTF・OTF・WOFF だけ
カードの文字に必要なサブセットだけが、それぞれ別の名前で Satori に渡ります。

これらのサブセットを共通のファミリー名 Noto Sans JP で登録したところ、次のように描画されました。

日本語タイトルのカード。サイト名、タイトル、サブタイトルのほぼすべての文字が空の四角で描かれ、いくつかの漢字だけが表示されている

同じファミリー名のうち Satori が読み込んだ1つのサブセットに、たまたま含まれていたわずかな文字だけが描画されています。

各サブセットに別々の名前を付け、すべての名前を fontFamily に並べると直ります。複数のファミリーを並べて指定すれば、Satori はその中で1文字ずつフォールバックするからです。

src/lib/og-card.ts
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 に指定します。

src/lib/og-card.ts
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行目の先頭に 次 が来ています。

繁体字中国語タイトルのカード。1行目が 五 で終わり、2行目が 次 で始まっていて、五次 という単語が分かれている

改行が 五 と 次 のあいだ、つまり1つの単語の内側に入っています。

ブラウザーにも Node.js にも組み込まれている Intl.Segmenter は、granularity: "word" を指定すると、両言語の辞書を使って単語を区切ります。og-card.ts はタイトルを単語に分け、CJK の各単語の文字と文字のあいだに U+2060 WORD JOINER を入れます。WORD JOINER は、その位置での改行を禁止する見えない文字です。

src/lib/og-card.ts
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のうち最大のものから選びます。

src/lib/og-card.ts
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 のタイトル帯より上に残った高さに合わせて決めます。

参考リンク

この記事をシェア