0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

next/og で日本語・中国語・韓国語の OG 画像を作る:フォントを 100KB に削って、抜けた文字をテストで捕まえる

0
Posted at

何を作ったか

子ども向けのタイピング練習サイト(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言語に対応しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?