やっていること

記事ごとの OGP 画像(1200×630)を事前の画像ファイルとして持っていません。 ビルド時に frontmatter のタイトルから生成しています。

構成は次の2段です。

  1. satori — JSX 風のオブジェクトツリーを SVG に変換する
  2. resvg — SVG を PNG にラスタライズする

Astro の動的ルートから記事ごとに呼び出すので、記事を追加すればその分の PNG がビルド出力に増えます。テンプレートを直せば全記事の OGP が一斉に変わります。

satori はシステムフォントを持たない

最初に踏むのがこれです。satori にフォントを渡さないと日本語が描画されません。 ブラウザや OS のフォントを勝手に使ってくれません。

const FONT = readFileSync(join(ASSET_DIR, "NotoSansJP-Bold.otf"));

const svg = await satori(tree, {
  width: 1200,
  height: 630,
  fonts: [{ name: "Noto Sans JP", data: FONT, weight: 700, style: "normal" }],
});

フォントファイルをリポジトリに置いて読み込み、fonts で渡します。そしてツリー側の fontFamily同じ名前を書きます。ここが食い違うと描画されません。

CSS のフォント指定に慣れていると「無ければ代替が使われる」と考えますが、代替候補が1つも無い環境だと思った方が正確です。

ベース画像の有無で描画を切り替える

デザイン付きのベース画像(card-base.png)を背景に敷いて、その上にタイトルだけを乗せる形にしています。

ただしベース画像が無くてもビルドが通るようにしました。

const baseUri = loadBase();
const tree = baseUri ? treeWithBase(title, fontSize, baseUri) : treeFallback(title, fontSize);

loadBase() はアセットを探して見つかれば data URI を返し、無ければ null を返します。null のときは satori だけでネイビーのグラデーションカードを描く別のツリーを使います。

理由は単純で、画像1枚の欠損でサイト全体のビルドを落としたくないからです。OGP は無くても記事は読めます。落とすべき失敗と、縮退して続けるべき失敗は分けたいところでした。

ベース画像は .png / .jpg / .jpeg の順に探して、最初に見つかったものを使います。

切り詰めず折り返す

長いタイトルの扱いは2択でした。

方式 結果
一定文字数で切って省略記号を付ける 収まるが、何の記事か分からなくなる
折り返す 3行になることもあるが、全部読める

折り返しを選びました。satori は maxWidth を指定すれば自動で折り返します。

maxWidth: "1000px",   // 左padding 88 + 1000 = 1088 < パネル右端 1162
lineHeight: 1.42,

maxWidth はベース画像のパネル幅から逆算しています。パネルの外へはみ出すと背景と喧嘩するので、内側で折り返させます。

OGP カードは一覧の中で小さく表示されるので、省略記号で切った1行より、小さくても全部読める複数行の方が伝わると判断しました。

フォントサイズを実測に合わせて直した

ここは一度作り直しています。

旧実装は、タイトルが長くなるほど段階的に小さくしていました。 26字を超えると 44px 以下、46字を超えると 35px まで縮小する形です。1行に収めようとした設計でした。

これを Zenn の OGP と並べたら、明らかに不自然でした。Zenn は長さに関係なくほぼ一定のサイズで、長い記事だけ文字が小さくなるということが起きません。実物のパラメータを見ると 55px 相当の指定でした。

そこで基準を変えました。

function titleFontSize(len: number): number {
  if (len <= 60) return 56;   // 通常長は一律。Zenn 実測 55px に合わせた
  if (len <= 80) return 48;
  return 40;
}

通常の長さは一律 56px にして、折り返しに任せます。 段階的な縮小は「超長文だけの安全弁」に格下げしました。長いタイトルほど小さくなる挙動が消えて、一覧に並べたときの見た目が揃いました。

そして実装の閾値が、コンテンツの規約を決めた

ここが今回いちばん書きたい部分です。

しばらく運用して記事が増えたあと、タイトルが全体的に長いことに気付きました。当時は「主題 ─ 補足」という形で書いていて、 以降が長さの大半を占めていました。

長さの判断基準を探して、まず外を見ました。Zenn の記事タイトルを96件集めて分布を測ると、中央値は全角換算で 31.5、75パーセンタイルが 39.5 でした。自分のところは平均 39.5・最大 46.5 だったので、明確に長い側でした。

しかしそれは相対的な話です。決め手になったのは自分の実装の閾値でした。

素の文字数が60を超えると、フォントが 56px から 48px に落ちます。 該当する記事だけ OGP の文字が小さく描画されます。一覧に並べたとき、その2本だけ見た目が違っていました。

そこで規約を決めました。

  • 全角換算 28 以下(目安 15〜26)
  • 素の文字数 60 以下をハード制約にする
  • 主題だけを書き、 の補足は付けない(補足は summary と重複していたので、削っても情報は失われない)

全37本を改題して、平均 39.5 → 20.5、最大 46.5 → 26.0 になりました。60字超は0件になり、全記事が 56px で統一されました。

面白かったのは順番です。「良いタイトルとは」から考えたのではなく、実装が持っていた閾値が規約を決めました。 外部の分布は「長い側にいる」ことを教えてくれましたが、どこで線を引くかは自分の描画コードが持っていました。

なお「検索結果は全角30字前後で切られる」という数字もよく見かけますが、一次情報で確認できなかったので判断には使っていません。使ったのは Zenn の実測分布と、自分の実装の閾値だけです。

ビルド時生成の副産物

改題したとき、OGP は何もしなくても追随しました。 ビルドし直せばタイトルから作り直されるので、画像の差し替え作業がありません。

事前に画像ファイルを作る方式だと、改題のたびに作り直すか、古いタイトルの画像が残り続けることになります。「記事は直したのに OGP だけ古い」という状態が起きません。

逆の性質もあります。OGP はビルド出力にしか存在しないので、リポジトリを見ても画像がありません。デプロイ前に見た目を確認したいときは、ローカルでビルドして出力を開くことになります。

まとめ

  • satori にはフォントを埋め込む。 システムフォントは使われません。ツリー側の fontFamily と名前を一致させてください
  • ベース画像は無くても描けるようにしておくと、アセット1枚の欠損でビルドが落ちません。落とすべき失敗と縮退すべき失敗は分けてください
  • 長いタイトルは切り詰めずに折り返す方が伝わります。maxWidth はパネル幅から逆算します
  • 長さに応じた段階縮小は「超長文だけの安全弁」に留める。 通常長を一律サイズにすると、一覧での見た目が揃います
  • 実装が持っている閾値は、コンテンツの規約になり得ます。 私の場合はフォントサイズの分岐点が、タイトルの文字数上限を決めました
  • ビルド時生成なら改題に自動追随します。代わりにリポジトリには画像が存在しません