p4ni.

チュートリアル

Satori で日本語の OG 画像:5MB のフォントはどこにも配信されない

· 読了まで約13分

目次

このサイトに日本語記事を追加したとき、OG 画像は触らずに済む部分だと思っていた。英語記事のカードはすでに全記事ぶん自動生成できていて、Satori でレイアウトし、resvg で PNG にして、Astro の静的エンドポイントがビルド時に両方を回すという構成が動いていた。ロケールを増やすのはルーティングの話で、ルーティングはもう解けている。

ルーティングの話ではなかった。Satori にとって欧文と和文は別の仕事で、しかも失敗の仕方が静かだ。壊れたビルドが、成功したビルドと同じ顔で通り過ぎる。

フォントを渡し忘れると NO GLYPH の箱が並ぶ

Satori は、渡したフォントに入っているグリフだけを描く。システムフォントを探しに行くことも、OS のフォールバックに落ちることも、フォントを取得することもない。なので最初に測る価値があるのは失敗のほうだ。Inter だけを渡し、日本語のタイトルを描かせるとこうなる。

日本語の文字がすべて NO GLYPH と書かれた黒い箱に置き換わった OG カード。欧文だけが正常に描かれている。

仮名も漢字も、NO GLYPH と刻まれた黒い箱になった。カテゴリラベルもタグラインも同じ。生き残ったのは Moltbook:AI と数字の 5 だけ。Inter が持っている文字がそれだけだった。

問題は見た目より手前にある。astro build は終了コード 0 で終わり、警告を1行も出さない。レンダリング中に console.warn をフックして確かめたが、グリフが1つ足りないときも40字連続で足りないときも Satori は何も言わない。ビルドは緑、PNG は正常なファイル、サイズもそれらしい数字になる(通常 37KB に対して 25KB)。カードだけが壊れている。自分が読めない言語のカードを生成するなら、パイプラインを信用する前に1枚は目で見るしかない。

どのフォントファイルを渡すか

前回の記事に書いた Satori の制約2つが、和文ではそのまま選択肢を決めてしまう。woff2 を読まない。可変フォントも読まない。欧文なら少し不便なだけで済む。ただ CJK のウェブフォントはほとんど woff2 でしか配られていない。圧縮率が要るのは元が大きいフォントなので、Satori が読めない形式に集まってしまう。

結局、静的な woff を2本、ウェイトごとに src/assets/og/ へ置いた。中身を fontTools で読むとこうなっている。

noto-sans-jp-400-normal.woff   2,654,856 bytes   13,895 glyphs   13,827 mapped codepoints
  CJK 統合漢字 (U+4E00-9FFF)             12,747
  かな (U+3040-30FF)                        189
  全角形 (U+FF00-FFEF)                      224
  ラテン (U+0020-024F)                      191

inter-latin-400-normal.woff       30,696 bytes      515 glyphs      230 mapped codepoints

和文は欧文の 86 倍ある。sfnt に展開すると 4.56MB で、Satori が実際にパースするのはこのサイズだ。入っていないものも見ておきたい。ハングルは無く、CJK 拡張 A も無い。これは日本語サブセットであって「CJK 全部」ではない。韓国語や繁体字も出すなら、そのぶんフォントを1本ずつ足すことになる。

ウェイトを1本にすると和文だけ細くなる

カードのタイトルはセミボールドで組んでいる。2.7MB を節約する素直な手は和文を1ウェイトだけにして合成ボールドに任せることだが、Satori は合成もしないし、要求したウェイトへ落とすこともしない。

同じ行の中で日本語がレギュラー、欧文がセミボールドで描かれた OG カード。太さが揃わず不揃いに見える。

和文は 400 のまま、同じ行にある Moltbook:AI は Inter の 600 で出る。ウェイトの解決はフォントファミリー単位なので、欧文の要求は 600 を見つけ、和文の要求は手元にある唯一のウェイトで黙って妥協した。1行に2つの太さが混ざると、デザインの選択ではなく描画のバグに見える。

つまり2ウェイトで 5.3MB を払うことになる。払う前に、そのデータがどこへ行くのかを確かめたい。

その 5.3MB はどこにも配信されない

エンドポイントは astro build の最中に、モジュールスコープで node:fs を使ってフォントを読む。出力は dist/ に書かれる PNG のバイト列だ。フォントの痕跡はそれ以外に何も残らない。

$ find dist -name '*.woff' | wc -l
0

dist/ には woff2 が3本あるが、それは本文を組むためにブラウザが取得するフォントで、この話とは関係ない。日本語の OG フォントはコンパイラと同じ、ビルド時だけの依存物だ。dist/ 全体は 8.7MB で、そのうち 1.6MB が生成された45枚のカードにあたる。

この記事の分かれ道はここにある。リクエストのたびに OG 画像を作る構成なら、フォントのバイト数はそのままバンドルのバイト数で、5.3MB は Worker のサイズ上限に対する現実の問題になる。Worker やサーバーレス関数の上に /og/[slug].png を置く形で、Satori の解説はたいていこちらを想定している。

静的サイトのビルド時生成なら、フォントのサイズが食うのはリポジトリのディスクだけだ。どちらの構成も妥当だが、制約は正反対を向いている。片方に向けて書かれた助言は、もう片方では有害になる。このサイトは Cloudflare Workers に静的配信で載せているので、安いほうの側にいる。

どこまで小さくできるか、そしてやらなかった理由

安い側にいることは、その数字がどうでもいいことを意味しない。日本語カードに出る文字を全部集めてみた。24本の記事タイトルに、サイト名、カテゴリラベル4種、タグライン、ドメインを足したものだ。その集合だけを pyftsubset で残すとこうなる。

ja カード全体で使う文字: 251 種  (CJK 190, ラテン 61)

noto-sans-jp-400   2,654,856 → 52,240 bytes   (350 glyphs)
noto-sans-jp-600   2,675,224 → 52,352 bytes   (350 glyphs)

フォントの 98% は、一度も描いたことがなく、おそらく今後も描かないグリフだった。漢字は 12,747 字入っていて、使っているのは 190 字。文字単位でサブセットすれば、2分のスクリプトで 5.2MB が消える。

それをやっていない。理由は技術ではなく運用にある。この文字集合はタイトルの関数なので、記事を1本書くたびに変わる。正しくやるならビルドの中でサブセットすることになる。コレクションを読み、コードポイントを集め、メモリ上でサブセットして Satori に渡す——依存が1つ増え、失敗しうる工程が1つ増える。それを、マシンの外に出ないバイトのために払う。カード生成をエッジに移すなら真っ先に作るが、今のところは誰も払っていない数字を最適化することになる。

エッジ側にいてこれをやるなら、全記事ぶんの文字集合を相手にする必要はない。これから描く1本のタイトルだけで足りる。数十グリフなので 20KB を切る。

全角1文字は2カラムとして数える

カードはタイトルが長くなるほど文字を小さくしている。英語版は title.length で数えていて、これは和文で即座に破綻する。仮名と漢字は正方形に描かれ、欧文2文字ぶんの幅を持つので、40字の和文は80字の欧文と同じ幅を占めるからだ。文字数で数えると、長い和文タイトルが「短い」と判定されて最大サイズを与えられる。

カラムで数えるのは1行で書ける。閾値の 0x2E7F は CJK 部首補助ブロックの直前にあたるので、日本語は全部広いほうに落ちる。

const cols = [...title].reduce((n, ch) => n + (ch.codePointAt(0)! > 0x2e7f ? 2 : 1), 0);
const titleSize = cols > 70 ? 46 : cols > 45 ? 52 : 60;

今このサイトにある日本語タイトル24本で cols は 40 から 103 まで散り、どのカードも4行クランプの内側、2行か3行に収まっている。同じ24本を文字数で数えたらどうなるかも調べた。24本のうち17本が違うサイズになり、そのうち3本は2段階大きくなる。いちばん外れるのは、40字という「短い」タイトルが73カラムを占めるこれだ。

週2日の在宅勤務は成果を落とさない:1,612人のランダム化比較試験が測ったもの
  半角 7 + 全角 33  =  73 カラム
  文字数で数える: 40 → 60px  → 3 行
  カラムで数える: 73 → 46px  → 2 行

壊れはしない。lineClamp: 4 がどちらでも受け止めるし、60px でも3行で済んで5行にはならない。ただこの段階制御はタイトルを2行に保ち、短いものを大きく見せるために置いてある。文字数で数えるとそれが逆立ちする。和文の比率が高いタイトルほど大きい文字を与えられて3行に膨らみ、見た目の幅が同じ欧文タイトルは最小サイズになる。

同じ方向の調整が小さく2つある。

負のトラッキングは欧文向けの詰めだ。英語カードは Inter を締めるために letterSpacing: -1.5 を入れている。これを和文にかけると1行に1文字多く入るが、全角グリフはもともと自前のサイドベアリングを持っているので、締まるのではなく窮屈になる。今のカードは letterSpacing: locale === 'ja' ? 0 : -1.5 にしてある。

Satori に禁則処理は無い。CJK は任意の2文字の間で折り返され、ブラウザが持っている行分割の規則は一切効かない。テスト文字列を1字ずつ伸ばしていくと、21字目で開き括弧が2行目の頭に落ちた。実タイトル24本ではまだ醜い割れ方は出ていない——日本語はどこでも折り返せるのが普通なので、そこは耐えてくれる。ただ括弧や引用符を含むタイトルを扱うなら、想像で済ませずカードを見たほうがいい。lineClamp は行数を数えるだけなので、どちらの場合も影響を受けない。

重いのはフォントではなくラスタライズ

4.56MB のフォントはビルド時間に出ると思っていた。カード1枚あたりの実測値をミリ秒で並べる。

                          satori (レイアウト)        resvg (ラスタライズ)
フル 2.65MB × 2       149.6  5.4  9.5  3.3  3.0     1965  1763  1753  1746
サブセット 52KB × 2    11.6  3.8  4.9  8.1  4.2     1819  1768  1741  1785

フルフォントのパースには 150ms かかる。ただし1枚目だけだ。そのあとは Satori がキャッシュを持ち、1枚のレイアウトは 3ms 前後で終わる。98% 小さいフォントがビルド全体で節約するのは 140ms ということになる。一方の resvg は1枚に 1.75 秒使い、フォントが何であろうと気にしない。

テキストが何かも気にしない。タイトルを空にした同じ 1200×630 のカードでも 1.68〜1.78 秒かかる。時間を使っているのはラスタライズと PNG エンコードで、グリフの複雑さは関係ない。実際のビルドログでもロケール間に差が出ていない。

├─ /og/moltbook-ai-agent-social-network.png (+1.81s)
├─ /og/ja/moltbook-ai-agent-social-network.png (+1.80s)

45枚、1枚あたり 1.73〜2.02 秒、1分26秒のビルドのうち約81秒。前の記事では「1枚目のコストが後続に薄まることはない」と書いた。そのとおりだったが、薄まらないのがどちらなのかは今回わかった。Satori のほうはきれいに薄まる。resvg が1枚ごとの固定料金だ。効く手は解像度を下げるか、ビルドをまたいで PNG をキャッシュすることで、フォントを削ることではない。

出力サイズも近い。英語カードの中央値が 35.4KB、日本語が 37.4KB。密な漢字のアウトラインは1枚あたり 2KB ほどの差にしかならない。

自分で確かめる方法

いちばん時間を節約したのは embedFont: false だった。通常 Satori はグリフのアウトラインを <path> に埋め込むので、出力からはどのフォントが選ばれたか読めない。embedFont: false にすると <text> 要素とメトリクスが出てきて、送り幅がフォントを白状する。全角グリフは widthfont-size と一致し、その値は和文フォントからしか出てこない。

const svg = await satori(card, { width: 1200, height: 630, fonts, embedFont: false });
// <text width="46" height="55.2" font-weight="600" font-size="46" ...>

24枚の PNG を目で見ずにタイトルごとの行分割を取り出したのも同じ方法だ。<text>y でグループ化し、各グループを x で並べて、行頭の文字を読む。

そのあとで実際のピクセルを見る。pnpm dev はこのエンドポイントをそのまま配信するので、/og/ja/<slug>.png はリロードするたびに描き直される。自分から名乗らない2つの失敗——NO GLYPH の箱とウェイトの不一致——を捕まえるには、ロケールごとに1枚見れば足りる。

まとめ

  1. 和文グリフが無いと NO GLYPH の箱が並び、終了コードは 0 で、警告も出ない。ロケールごとに1枚は目で見る
  2. woff2 と可変フォントは Satori にとって存在しない。静的な woff / ttf が必要で、CJK ではそれがメガバイト単位になる
  3. ファミリーに1ウェイトでは足りない。同じ行で和文が 400、欧文が 600 になるのが症状
  4. ビルド時生成ならフォントのサイズは問題にならず、リクエスト時生成なら最大の問題になる。サイズの助言を読む前に、自分がどちらなのかを決める
  5. 使う文字だけに絞れば 98% 落ちる(2.65MB → 52KB)。ただし自動化する価値があるのは、そのバイトが配信されるときだけ
  6. 文字を縮めるときは文字数ではなくカラム数で数える。負のトラッキングは和文では外す
  7. 1枚あたりの時間は resvg が 1200×630 をラスタライズする約 1.75 秒で、フォントや言語では動かない

時間を見積もっていたのはロケールのルーティングのほうで、そこには驚くことが何も無かった。測らないと分からないことは全部フォント側にあった。