OGP 画像をビルド時に Skia で描く
この記事を SNS に貼ると、タイトルの上に一枚の画像が出る。その画像は dist/og/
に書き出された PNG で、ビルドのときにこのサイト自身が描いている。
画像を描く道具の選び方
ページを実際に開いて撮る方法がある。ヘッドレスブラウザでスクリーンショットを撮れば、見た目はページそのものになる。そのかわり CI がブラウザを一つ抱えることになり、生成にかかる時間もブラウザの起動時間に縛られる。
このサイトは canvaskit-wasm
を使う。ブラウザが文字を並べるのに使っている Skia を、WebAssembly
に載せたものである。長方形に文字を置くだけなら描画ライブラリは何でもよいが、置くのがタイトルとなるとそうはいかない。タイトルの長さは決まっていないのに、収める箱の幅は決まっている。字形を並べ、幅で折り返し、行数で打ち切って省略記号を付ける必要がある。Skia
の paragraph API はそれをやる。
カードの文面はどこから来るか
ページのモジュールは title と description を export している。<head>
に入るのと同じ値なので、カードもそこから作る。
const image = ogImage(routes.showcase.href(), Showcase);
この一回の呼び出しが、カードの描き方を記録し、<meta property="og:image">
に入れる URL を返す。登録せずに URL だけ受け取ることも、URL
を受け取らずに登録することもできない。カードのためにページの一覧をもう一つ書けば、いつか片方だけが更新される。
クロールが辿れない画像
このサイトの静的ビルドは、router.tsx をそのまま fetch() して、返ってきた
HTML の中のリンクを辿って広がっていく。辿り着けるものが生成されるものである。
カードはそこから漏れる。og:image はリンクを表示する側のサーバーが取りに来る
URL なので、絶対 URL
で書く。クローラーがそれを辿ったら、このサイトの外に出てしまう。
そこで router.tsx の entryPoints がカードの一覧を持つ。
export const entryPoints: readonly string[] = ["/", ...ogPaths()];
ogPaths() が読む登録簿は、ルートを配線するときに埋まる。だからこの export
はファイルの末尾にある。
日本語のカード
Skia には CSS の font-family
に当たる仕組みがないので、フォントの実体を渡す。server/og/fonts/ に置いた
.ttf がファイル名順に登録され、Skia は一文字ずつその順に字形を探す。
Inter が先に並び、Noto Sans JP が後に続く。だから日本語の見出しに HTML
が混ざっても、かなと漢字は Noto、HTML は Inter のまま出る。
日本語のウェイトは Regular だけを置いてある。太字の face を持たないファミリには、Skia が合成の太字をかける。本物の太字をもう一枚積めば 2MB 増えるので、この大きさなら合成で足りると判断した。
Noto Sans JP は JIS X 0208 に絞ってある。かなと記号、第 1 水準と第 2 水準の漢字 6,355 字で 2.2MB になる。フォント全体なら 5.3MB である。
絞ったぶん、外れる字がある。鷗 のように JIS X 0213
にしかない字は、このフォントに字形がなく、空白として描かれる。名前から一文字消えていても、誰も開かない画像の上でのことである。だからビルドが言う。
og: no glyph for 鷗 in /blog/mori-ogai — see server/og/fonts/README.md
エラーではなく警告にしてある。一文字のためにデプロイを落とす理由はないし、直し方はコードではなくフォントのファイルだからである。フォントを置く場所はこのディレクトリしかないので、全体版に差し替えれば警告は消える。