何を作ったか
子ども向けのタイピング練習サイト(Tanepo)を個人で作っています。タイムチャレンジの結果をシェアすると、スコアを焼き込んだカード画像が X などのプレビューに出ます。
UI は7言語あるので、カードもシェアした人の言語で出したい。ところが next/og の ImageResponse で日本語を描くと、文字が豆腐(□□□)になります。しばらくは、どの言語でシェアしても英語のカードが出ていました。
この記事は、そこから「日中韓のカードを、軽く、文字抜けなしで出す」までの記録です。
なぜ豆腐になるか
next/og の中身は satori で、JSX を SVG にしてから PNG にします。satori が内蔵しているフォントはラテン文字だけです。スペイン語・フランス語・イタリア語のアクセント(é, ñ, à)は内蔵フォントで描けますが、漢字・かな・ハングルは描けません。
解決策は「フォントファイルを渡す」だけですが、Noto Sans JP を丸ごと読むと 1ファイル数MB〜十数MB あります。カード画像のリクエストのたびにそれを読むのは重すぎます。
カードに描きうる文字だけのサブセットを作る
カードに出る文字列はすべて UI の辞書と絵本のタイトルから来ていて、利用者の自由入力はありません。なので「描きうる文字の集合」を事前に作れます。
// scripts/make-og-fonts.ts(抜粋)
export function cardCharset(locale: UiLocale): string {
const m = MESSAGES[locale];
const strings = [
...Object.values(m.card),
...Object.values(m.progress),
...Object.values(m.result),
m.challenge.title,
// ……カードに出る文言
...SEED_STORIES.map((story) => story.localizedTitle[locale] ?? story.title),
"Tanepo 0123456789 %·/.,:-—!?()()「」『』、。/WPMPPMMPM 🌱🏆🎯",
].filter((v): v is string => typeof v === "string");
return [...new Set(strings.join(""))].sort().join("");
}
ポイントは、カードが今使っている文言よりも少し広く集めていることです。チャレンジ・成長記録・結果画面の語彙をまるごと入れておくと、あとでラベルをカードに移したときに文字が消える事故が起きにくくなります。
サブセット自体は Google Fonts の text= パラメータに作らせています。
const url =
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@700` +
`&text=${encodeURIComponent(text)}`;
できたファイルはリポジトリにコミットします。日本語(Noto Sans JP)で約100KB、中国語(Noto Sans SC)で約80KB、韓国語(Noto Sans KR)で約50KB です。
ハマりどころ:User-Agent で形式が変わる
Google Fonts は User-Agent を見て返すフォント形式を変えます。今どきのブラウザの UA だと WOFF2、古い IE の UA だと EOT が返ります。satori はどちらも読めません(TTF / OTF / WOFF は読めます)。
素の Mozilla/5.0 を UA にすると TTF が返ってきます。
const TTF_UA = "Mozilla/5.0";
const css = await fetch(url, { headers: { "User-Agent": TTF_UA } }).then((r) => r.text());
形式を間違えると、描画のときに初めて Unsupported OpenType signature で落ちます。なので、ダウンロードした時点で先頭4バイトを見て弾いています。
const tag = buf.subarray(0, 4).toString("latin1");
if (!["\u0000\u0001\u0000\u0000", "true", "OTTO", "wOFF"].includes(tag)) {
throw new Error(`${family}: expected TTF/OTF/WOFF`);
}
描画側
言語ごとにフォントを選び、日中韓以外は何も渡しません。
// src/lib/og/fonts.ts(抜粋)
export async function cardFonts(locale: UiLocale): Promise<CardFont[]> {
const font = CJK[locale];
if (!font) return [];
try {
const data = await readFile(path.join(process.cwd(), "public", "og-fonts", font.file));
return [{ name: font.name, data: /* ArrayBuffer */, weight: 700, style: "normal" }];
} catch {
return [];
}
}
catch で空配列を返しているのはわざとです。フォントが読めなくても、英語っぽいカードなら出せます。500 を返すと、投稿にはカード画像そのものが出ません。
本当に怖いのは「文字が抜ける」こと
サブセットに入っていない文字は、エラーにならず、何も描かれません。「タイムチャレンジ」が「タイムチャ ンジ」になっても、ビルドもテストも通ります。しかもカード画像は、誰かが投稿した先で初めて人の目に触れます。気づくのが一番遅いところで壊れます。
辞書の文言を1つ直しただけで起きうるので、テストで捕まえることにしました。フォントファイルの cmap テーブル(文字コード → グリフの対応表)を直接読み、カードに出うる全文字が入っているかを確かめます。
// tests/unit/og-fonts.test.ts(抜粋)
function codePointsIn(file: string): Set<number> {
const buf = readFileSync(file);
const numTables = buf.readUInt16BE(4);
let cmapOffset = -1;
for (let i = 0; i < numTables; i++) {
const rec = 12 + 16 * i;
if (buf.toString("latin1", rec, rec + 4) === "cmap") cmapOffset = buf.readUInt32BE(rec + 8);
}
const found = new Set<number>();
const subtables = buf.readUInt16BE(cmapOffset + 2);
for (let i = 0; i < subtables; i++) {
const off = cmapOffset + buf.readUInt32BE(cmapOffset + 4 + 8 * i + 4);
if (buf.readUInt16BE(off) !== 4) continue; // Google が返すのは format 4
const segX2 = buf.readUInt16BE(off + 6);
const ends = off + 14;
const starts = ends + segX2 + 2;
for (let s = 0; s < segX2 / 2; s++) {
const end = buf.readUInt16BE(ends + s * 2);
const start = buf.readUInt16BE(starts + s * 2);
if (start === 0xffff) continue;
for (let c = start; c <= end; c++) found.add(c);
}
}
return found;
}
依存ライブラリなしで、format 4 のセグメント(開始コード〜終了コード)を舐めるだけです。テストは「cardCharset(locale) の文字のうち、cmap にないもの」を集めて、空文字列であることを確かめます。絵文字はフォントではなく描画側が処理するので、対象から外しています。
expect(missing.join("")).toBe("");
失敗すると、抜けている文字がそのまま出ます。辞書に新しい文言を足した人は、npx tsx scripts/make-og-fonts.ts を実行してフォントを作り直せば直ります。
デプロイでも確かめる
テストに加えて、デプロイスクリプトの最後で本番のカード画像を実際に取得し、本物の PNG か(先頭のマジックナンバーと、10KB 以上あるか)を毎回確認しています。さらに、シェア用の URL の og:image がその画像を指しているかも見ます。カードは壊れても静かに壊れる(アプリは動いていて、投稿のプレビューだけが空になる)ので、目で見る代わりに機械に見させています。
おまけ:サイト全体の OG 画像は静的ファイルにした
スコアごとに違うカードは動的に生成するしかありませんが、サイト全体の OG 画像は言語ごとの PNG(og.png、og-ja.png…)をコミットしています。最初は opengraph-image ルートで生成していましたが、X のクローラーが拡張子のない URL の画像を拾わなかったためです。
まとめ
- satori はラテン文字しか持っていないので、日中韓はフォントを渡す
- 描きうる文字が決まっているなら、Google Fonts の
text=でサブセットを作れば 100KB 前後に収まる - Google Fonts は UA で形式を変える。satori 用には素の
Mozilla/5.0で TTF を取る - サブセットの文字抜けはエラーにならない。
cmapを読むテストで捕まえる
作ったサイトはこちらです。無料・広告なし・登録なしで、7言語に対応しています。