Web

satoriの禁則処理とフォント収録範囲を自前のロジックで直した

  • Astro
  • OGP
  • Satori
  • TypeScript
  • 日本語フォント

satoriはCSSの lineBreak プロパティを実装していません。wordBreak: 'keep-all' を指定しても、行頭・行末禁則を守るロジックは内部に存在しないので、日本語タイトルは幅が尽きた場所でそのまま折り返されます。プロダクトサイトのOGP画像でこれが実際に事故を起こしたので、行分割を自前で計算してsatoriに渡す形に直しました。あわせて見つかったフォントの収録範囲の問題も、同じ根っこの話として扱います。

// satori本体(0.29.0)の分岐。keep-all / break-allはrequiredBreaksを返さない
if (wordBreak === 'keep-all') {
  return { words: segment(content, 'word'), requiredBreaks: [] }
}
ブログCloudflare Workers で @vercel/og が使えなかったので Satori と Resvg WASM で OGP を組んだAstroプロジェクトで動的OGP画像を自動生成する方法を解説します。Cloudflare Workers環境の制約から@vercel/ogではなくSatoriと@resvg/resvg-wasmの組み合わせを選び、ビルド時に1200x630pxのPNG画像を静的生成する設計を紹介します。

この記事の内容は satori 0.29.0(コミット b712d5b)で確認したものです。lineBreak を実装していないという前提は、将来のバージョンで変わる可能性があります。同じ症状に当たったときは、まず手元のバージョンで src/text/processor.tspreprocess()lineBreak を参照しているかを確認してください。

OGP画像の日本語が崩れる

本番のOGP画像を開いて確認したところ、2種類の崩れが見つかりました。

1つ目は禁則違反です。あるLPページのタイトルが3行に折り返り、最終行が長音符「ー」1文字だけの孤立行になっていました。もう1件は開き括弧の直後で改行され、閉じ括弧だけが次の行に取り残されていました。

2つ目はフォントの欠字です。説明文に入っていた矢印「→」が、グリフの無い文字を表す欠字ボックス(いわゆる「豆腐」)で描画されていました。どちらもCSSの指定を疑って直せる類の不具合ではなく、satoriの内部を読みに行くところから始まりました。

satoriはlineBreakを読んでいない

satoriのソースを確認すると、テキストの前処理を担う preprocess() はスタイルから textTransformwhiteSpacewordBreak だけを取り出していて、lineBreak は参照すらしていません。テンプレート側でどう指定しても、その値がsatoriの処理に届くことはありません。

// src/text/processor.ts(satori 0.29.0)
const { textTransform, whiteSpace, wordBreak } = style

行の分割位置を決めている splitByBreakOpportunities() を見ると、wordBreak: 'keep-all'break-all の2つの分岐は、どちらも requiredBreaks: [] を返しています。禁則を守るべき「ここで区切ってはいけない」という情報を持たないまま、幅だけで区切っているということです。CJK文字をまとまりごと扱うために keep-all を選んだ時点で、禁則判定の材料そのものが失われています(satori 0.29.0のutils.ts)。

vercel/satori#687 では、行分割に使っている linebreak ライブラリがUnicode 13ベースで止まっていて、CJK文字を含む場面で挙動がずれることが報告されています。CSSプロパティを指定すれば動くはずという前提そのものが、この箇所には通用しません。

自前で禁則処理を実装する

satoriに行分割の判断をさせない、というのが選んだ方向です。フォントサイズと表示幅から先に折り返し位置を計算し、1行ごとの文字列としてsatoriへ渡します。satoriが受け取った時点で改行はすでに確定しているので、判断の余地を残しません。

禁則のルールはW3CのRequirements for Japanese Text Layoutが扱う行頭禁則・行末禁則(JIS X 4051に沿った規定)をそのまま使っています。「、」「)」「ー」のような文字は行頭に来てはならず、「(」のような文字は行末に来てはならない、という禁則文字の集合をSetで持ち、区切り位置がその集合に触れていたら1文字ずつ前に送って合法な位置まで動かします。

function legalBreak(chunks: string[], index: number): number {
  let i = index
  while (i > 0) {
    const nextStart = chunks[i]?.[0]
    const prevEnd = chunks[i - 1]?.at(-1)
    const startsIllegally = nextStart !== undefined && NO_LINE_START.has(nextStart)
    const endsIllegally = prevEnd !== undefined && NO_LINE_END.has(prevEnd)
    if (!startsIllegally && !endsIllegally) return i
    i--
  }
  return -1
}

legalBreak を通すと、幅だけで区切った場合と改行位置がどう変わるかは次のようになります。「ー」は行頭禁則文字なので、その手前で区切ると行頭に来てしまい、1文字分だけ前の行へ送られます。

幅だけで区切ると行頭禁則文字の「ー」が次の行に1文字だけ残る。legalBreakは区切り位置を1文字ぶん前へ送り、行頭禁則に触れない位置まで動かす

もう1つ、幅の見積もりだけでは説明が付かない崩れもありました。最終行が1文字だけ残る孤立行です。禁則には違反していなくても、見た目としては折り返しの失敗にしか見えません。前の行から1文字を送って埋め合わせる処理を別に用意し、送った先が新たに禁則に触れる場合は2文字、3文字と送る文字数を増やしています。長音符や小さな「っ」のような行頭禁則の文字がちょうど境目に来るケースは、1文字送るだけでは解決しないためです。

フォントの収録範囲もメタデータ通りではなかった

矢印の欠字を追うと、原因は別の場所にありました。OGP生成で読み込んでいたNoto Sans JPは、japaneseサブセットとlatinサブセットの2つだけで、矢印記号のコードポイントはどちらにも収録されていません。CSSの指定ミスではなく、そもそも該当するグリフを持つフォントを読み込んでいませんでした。

japaneseとlatinのサブセットだけでは矢印のグリフが無く欠字ボックスになる。矢印を収録したサブセットを追加登録すると正しく描画される

メタデータではなくバイナリを読む

対応の途中で「収録範囲をCSSの unicode-range メタデータからそのまま引けばよいのでは」という案も出ましたが、実測すると成立しませんでした。CJK統合漢字のブロックは20,992文字ありますが、japaneseサブセットが実際に収録しているグリフはそのうち6,356文字ほどで、割合にすると3割程度です。ブロック単位で「収録済み」とみなすと、大半の漢字を誤って判定することになります。

japaneseサブセットにはCSSの unicode-range が付与されていないため、メタデータの側には正確な範囲が書かれていません。正確に判定する手段は、フォントファイルの cmap テーブルを直接パースすることでした。WOFF1はSFNTテーブルをzlib圧縮で格納しているだけなので、専用ライブラリを増やさずに読めます。

// cmapから実際に描画できるコードポイントの集合を得る
export function coveredCodepoints(woffBuffer: Buffer): Set<number> {
  const cmap = readWoffTables(woffBuffer).get('cmap')
  // format 4 / format 12のサブテーブルを読み、実収録のコードポイントを集める
}

不足しているコードポイントが見つかったら、@fontsourceが配布している番号付きサブセット(unicode.json に範囲が公開されています)から該当するものを逆引きして追加登録します。ハードコードした範囲表を持たずに済むので、フォント側の更新にも追従できます。

satoriのフォールバックの制約

サブセットを読み込む段になって、もう1つの制約に当たりました。satoriはfamily名とweightの組み合わせで1つのフォントだけを解決し、同じfamily名で複数のフォントを登録しても後から登録した側にフォールバックしません。追加のサブセットを同じ「Noto Sans JP」というfamily名で登録しても、satoriの内部では無視されます。

サブセットごとに別のfamily名を割り当て、テンプレート側でCSSのフォールバックチェーンとして並べる形に変えたところ、矢印は正しく描画されるようになりました。

推定した幅は、実際のレイアウトとは常にずれうる

行分割の計算に使っている文字幅は、全角文字を正方形、半角文字をその半分として見積もった近似値です。satori自身が持っている実際のフォントメトリクスとは別物なので、行の折り返し位置は常に多少のずれを含みます。

このずれを検知する手段として、テンプレートを実際にレンダリングした画像に対して監査スクリプトを走らせています。グリフの欠落、行頭・行末禁則の違反、孤立行、はみ出しをそれぞれ数えるもので、対象サイトの主要なOGP画像を数百枚まとめて処理しても違反ゼロという状態を保っています。新しいテンプレートを追加したときや、タイトルが想定より長いコンテンツが増えたときは、この監査を通してから公開するようにしています。