先日、Antigravity Lab に新しい記事を公開して、いつものように X とスレッズにリンクを貼りました。プレビューに出てきたシェア画像を見て、手が止まりました。英語の補助テキストは綺麗に出ているのに、肝心の日本語タイトルだけが、四角い豆腐(□□□□)の行列になっていたのです。
個人開発で複数のサイトを回していると、こういう「片方の言語だけ静かに壊れている」不具合がいちばん厄介です。エラーは出ません。ビルドも通ります。ただ、共有されたときにだけ恥ずかしい姿で世に出ていきます。
ここでは、その豆腐を退治するまでの実装を、つまずいた順に残します。題材は next/og の ImageResponse ですが、本質は「Antigravity のようなエージェントに OG 画像ルートを書かせると、ほぼ必ずこの罠に落ちる。なぜ落ちるのか、どう直させるのか」という一点に集約されます。
静かに失敗していたのは、フォントを渡していなかったから
最初のルートは Antigravity に書かせました。指示は素朴で、「記事タイトルを大きく入れた 1200×630 の OG 画像を opengraph-image.tsx で返して」というものです。エージェントは数秒で、いかにも正しそうなコードを出してきました。<div> を並べてタイトルを流し込み、ImageResponse で返すだけ。ローカルの英語タイトルでは、確かに描画されました。
ところが日本語が豆腐になる。ここで多くの方が font-family の指定を疑いますが、原因はもっと手前にあります。
ImageResponse の内部は Satori という、HTML/CSS を SVG に変換するエンジンです。そして Satori は、自分でフォントを一切同梱しません。fonts 配列で明示的に渡されたフォントの中に該当するグリフが無ければ、その文字は黙って欠落します。ラテン文字が出ていたのは、Satori が内蔵で持っているごく基本的な字形にたまたま含まれていたからで、日本語は最初から「持っていない字」だったわけです。
エージェントが出したコードには fonts の指定がありませんでした。エラーにならないので、エージェント自身も「成功した」と判断してしまう。ここが、人間が一度は目で確かめないといけない境界です。
woff2 を渡しても直らない、という二段目の罠
「ではフォントを渡せばいい」と、Antigravity に fonts を追加させると、今度は別の壁に当たります。多くのエージェントは、よくある書き方として Google Fonts の woff2 を fetch して渡そうとします。これも静かに失敗します。
理由は、Satori(および内部のラスタライザ)は woff2 を解釈できないからです。woff2 は Brotli で圧縮されたフォント形式で、Satori が直接読めるのは TrueType(ttf)・OpenType(otf)・woff までです。woff2 を渡すと、描画されないか、ランタイムによっては例外で落ちます。
ここを表で整理しておきます。
| 形式 | Satori で使えるか | 典型的な入手元 | 備考 |
| woff2 | 不可 | ブラウザ向け Google Fonts | Brotli 圧縮。最も配られやすいが Satori は読めない |
| woff | 可 | 一部 CDN | 読めるが入手しづらい |
| ttf / otf | 可 | css2 をサーバから取得 | 本記事で使う。サブセットも効く |
つまり目標は明確です。「描画する日本語の字だけを含んだ TrueType を、エッジで軽量に手に入れる」こと。Noto Sans JP のフルセットは数 MB あり、リクエストのたびに丸ごと取り寄せるのは現実的ではありません。必要なのは、タイトルに出てくる十数文字だけです。
必要な字だけを取り寄せる — css2 の text= サブセット
Google Fonts の css2 エンドポイントには、text= という嬉しいパラメータがあります。指定した文字だけを含むサブセットの CSS を返してくれるので、フォントのダウンロード量を劇的に削れます。
ここで一つ、ほとんど語られない肝があります。css2 は リクエスト元の User-Agent によって返すフォント形式を変えます。モダンブラウザの UA なら woff2、それ以外(サーバ側 fetch のように woff2 対応とみなされない UA)なら ttf を返します。エッジランタイムの fetch はブラウザの UA を名乗らないため、何もしなければ ttf が返ってくる——これがそのまま Satori に渡せる形です。逆に、気を利かせてブラウザ風の UA を付けると woff2 が返ってきて、また豆腐に戻ります。
サブセットを引き当てるヘルパーは、こう書きます。
// 描画する文字だけを含む TrueType サブセットを Google Fonts から取得する
async function loadGoogleFont(
family: string,
weight: number,
text: string,
): Promise<ArrayBuffer> {
// 文字を重複排除して text= を短く保つ(css2 の text= には長さ上限がある)
const glyphs = Array.from(new Set(Array.from(text))).join("");
const cssUrl =
`https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}` +
`:wght@${weight}&text=${encodeURIComponent(glyphs)}`;
// ⚠️ あえてブラウザ風の User-Agent を付けない。
// 付けないことで css2 は woff2 ではなく truetype を返す。
const css = await fetch(cssUrl).then((res) => res.text());
const src = css.match(
/src:\s*url\((.+?)\)\s*format\('(?:opentype|truetype)'\)/,
);
if (!src) {
throw new Error("TrueType サブセットの URL を解決できませんでした");
}
return fetch(src[1]).then((res) => res.arrayBuffer());
}
text= に渡すのは、実際に描画する全文字です。私はここで一度つまずきました。タイトルの文字だけをサブセットに含め、固定ラベルの「Antigravity Lab」やドメイン表記、それに溢れ処理で足す三点リーダー(…)を入れ忘れたのです。結果、タイトルは出るのに固定ラベルだけがまた豆腐になりました。サブセットは「画面に出る文字の集合」と一致させる、と覚えておくと安全です。
フォントを Satori が読める形で渡す
サブセット ttf が手に入れば、あとは ImageResponse の fonts に渡すだけです。opengraph-image.tsx の全体像を示します。
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
const BRAND = "Antigravity Lab";
const DOMAIN = "antigravitylab.net";
export default async function Image({
params,
}: {
params: { slug: string };
}) {
const rawTitle = await getArticleTitle(params.slug); // 記事メタから取得
const title = clampByWidth(rawTitle, 44); // 溢れ対策(後述)
// 描画する全文字をひとまとめにしてサブセットを引く
const glyphSource = title + BRAND + DOMAIN + "…";
const fontData = await loadGoogleFont("Noto Sans JP", 700, glyphSource);
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
width: "100%",
height: "100%",
padding: 80,
background: "#0b1020",
color: "#ffffff",
fontFamily: "Noto Sans JP",
}}
>
<div style={{ display: "flex", fontSize: 30, opacity: 0.7 }}>
{BRAND}
</div>
<div
style={{
display: "flex",
fontSize: 64,
fontWeight: 700,
lineHeight: 1.25,
}}
>
{title}
</div>
<div style={{ display: "flex", fontSize: 26, opacity: 0.6 }}>
{DOMAIN}
</div>
</div>
),
{
...size,
fonts: [
{ name: "Noto Sans JP", data: fontData, weight: 700, style: "normal" },
],
},
);
}
細かい点ですが、テキストを持つ要素には display: "flex" を明示しています。Satori は <div> を既定で flex 扱いにするものの、子が複数あるのに display を省くと崩れたり警告が出る場面があり、明示しておくほうが事故が減ります。フルスタックの CSS の直感がそのままは通用しない、というのが Satori と付き合う上での前提です。
文字溢れは、Satori の外で潰す
OG 画像でもう一つ現実的な問題が、長いタイトルの溢れです。Satori は text-overflow: ellipsis のような CSS を完全にはサポートしておらず、-webkit-line-clamp 系も期待どおりに効かないことがあります。私は早い段階で、レイアウトに頼らず、描画前に文字列を切るほうが堅いと判断しました。
日本語と英語が混ざるタイトルを、全角・半角を雑に重み付けして切るだけの素朴な関数で十分実用になります。
// CJK を全角換算で重く見積もり、見た目の幅で切る素朴な近似
function clampByWidth(text: string, maxUnits: number): string {
let width = 0;
let out = "";
for (const ch of Array.from(text)) {
width += /[\x20-\x7e]/.test(ch) ? 1 : 2; // 半角=1, それ以外=2
if (width > maxUnits) return out.trimEnd() + "…";
out += ch;
}
return out;
}
完璧な行分割アルゴリズムではありません。けれど OG 画像は一瞬で消費される画像で、ピクセル単位の最適配置より「破綻して見えないこと」のほうがずっと大事です。私はこの割り切りで、4 サイトすべての OG ルートを同じ実装に統一できました。
実測:サブセット化で何が変わったか
サブセットが効くと言われても、桁が見えないと判断できません。手元の Antigravity Lab のルートで、典型的な日本語タイトル(約 30 文字)を 1 枚描いたときの概算を載せます。環境やタイトルで変動する前提の、あくまで自分の計測値です。
| 項目 | フルフォント方式 | text= サブセット方式 |
| 取得するフォント量 | 約 1.6〜5 MB | 約 20〜50 KB |
| エッジでのコールド描画 | 遅い・タイムアウト懸念 | おおむね 250〜450ms |
| 日本語の描画 | 形式次第で □ になる | 正しく描画 |
| 実装の壊れやすさ | UA・形式で静かに失敗 | 形式が ttf に固定され安定 |
数 MB から数十 KB へ、削減率にして約 98% 以上。本番運用でタイムアウトを警戒していた描画が、安定して 1 秒を切るようになりました。二桁 KB まで落ちると、css2 への問い合わせとフォント取得を毎リクエスト走らせても十分軽いのですが、それでもコールド描画ごとに外部 fetch が二回挟まるのは無駄です。私はサブセットを、描画文字のキーでモジュールスコープの Map にメモ化しています。
const fontCache = new Map<string, Promise<ArrayBuffer>>();
function cachedFont(family: string, weight: number, text: string) {
const key = `${family}:${weight}:${Array.from(new Set(text)).sort().join("")}`;
if (!fontCache.has(key)) {
fontCache.set(key, loadGoogleFont(family, weight, text));
}
return fontCache.get(key)!;
}
加えて、OG ルート自体は記事の更新頻度に比べて変化が遅いので、CDN 側のキャッシュ(opengraph-image のレスポンスキャッシュや、サイト全体の DEPLOY_VERSION による失効)に乗せておけば、外部問い合わせはさらに減ります。
このバグを、エージェントに正しく直させる
ここまでが実装ですが、本題は「次に同じことが起きたとき、Antigravity に最短で直させるにはどう頼むか」です。私が学んだのは、エージェントに制約と検証手段をセットで渡すことでした。
うまくいかなかった頼み方は、「OG 画像の日本語が出ないので直して」でした。エラーが無いため、エージェントは原因を推測で当てにいき、font-family を足したり別の woff2 を試したりと、的外れな修正を重ねます。
うまくいった頼み方は、次の三つを最初に渡すやり方です。第一に、失敗の証拠。豆腐になった PNG を実際に見せ、「英語は出る・日本語だけが □」という症状を明示します。第二に、動かない理由の制約。「Satori は woff2 を読めない」「サブセットは描画する全文字を含める」という二つの事実を、推測させずに前提として与えます。第三に、合否を機械で判定する手段。生成した PNG を保存し、想定文字が描画されているかを確認する検証を、修正とセットで回させます。
検証は簡易でも効果があります。たとえば、生成画像を一旦ファイルに書き出し、サイズがゼロでないこと・前回の豆腐版とバイト列が変わっていることをエージェント自身に確かめさせるだけでも、「直ったつもり」を防げます。最終的には、私自身が一度だけ目で見て、豆腐が消えたことを確認します。エージェントは描画はできても、「正しく見えているか」の最終判断は人間が持つべき境界だと、この件であらためて感じました。
ここまでの遠回りを避けるため、私からのお勧めは一つだけです。OG ルートを新設するときは、最初の一回だけ必ず日本語タイトルで実画像を出力し、自分の目で確かめること。この一手間が、豆腐の本番流出をいちばん確実に回避してくれます。
何を任せ、何を自分で確かめるか。OG 画像のような「壊れてもエラーにならない」領域こそ、その線引きが効きます。次に新しいサイトを立てるときは、この loadGoogleFont と clampByWidth、そして「失敗の証拠・制約・検証をセットで渡す」頼み方を、最初からテンプレートとして持ち込むつもりです。お読みいただきありがとうございました。同じ豆腐に悩む方の遠回りが、少しでも短くなれば嬉しいです。