PillowでOG画像を自動生成:日本語タイトルの改行と禁則処理

全記事に1200×630のog:imageをビルド時に生成。Notoフォントの扱い、カタカナ語や漢字熟語を割ってしまった3つの失敗、助詞での改行と禁則処理、キャッシュとメタタグ設定まで。

今週まで、このブログの記事にはog:imageが1枚もありませんでした。SNSで共有しても文字だけのプレビューになり、記事のJSON-LDにも image がありませんでした。この記事は、静的サイトで記事ごとのOG画像を自動で作りたい人、特に日本語タイトルをきれいに改行したい人向けです。自作の静的サイトジェネレーターにPillowのカード生成を足した話と、日本語タイトルがまともな位置で改行されるまでに出した「おかしなカード」3枚の記録です。

カードの中身と、ビルド時に描く理由

全記事に1200×630のPNGを1枚ずつ、ビルド中に scripts/og_card.py(Pillowのコード142行)で描きます。入れるのは次のものです。

  • 記事タイトル(4行に収まる範囲でできるだけ大きく)
  • カテゴリ名(カテゴリごとのアクセント色)
  • 左下にブランド「t.laboratory」、右下にドメイン
  • 暗い背景に薄い40pxの方眼と、左端の14pxのアクセントバー

すべてコードで描くので、画像の権利で悩むことがありません。ストック写真も生成AIの画像も使っていません。外部の素材はフォントだけで、そのライセンスもはっきりしています(次の節)。ビルド時に作るので、カードのない記事が公開されることもありません。ビルドが集めた記事すべてに対して draw_card を呼びます。

# scripts/build.py(抜粋)
for p in posts[lang]:
    …
    draw_card(OUT / "static" / "og" / lang / f"{p['slug']}.png", p["title"], p["cat"]["name"],
              lang, p["category"], brand=site["brand"])
    p["og_image"] = f"{base}/static/og/{lang}/{p['slug']}.png"

draw_card(OUT / "static" / "og" / "default.png", site["langs"]["en"]["tagline"], "t.laboratory",
          "en", "web", brand=site["brand"])
フローチャート:記事のフロントマター(タイトル、カテゴリ、言語)を、デザインのバージョンやブランド名と一緒に md5 のキャッシュキーにする。そのキーの PNG がキャッシュにあればコピーし、なければ Pillow と Noto フォントで描いてキャッシュに保存する。どちらの場合も結果は /static/og/<lang>/<slug>.png に書き出され、その URL が og:image と JSON-LD の image に入る。
ビルド時に記事ごとのカードができるまで。入力が同じならキャッシュをコピーするだけで、描き直さない。

フォント:Notoの可変フォントをPillowで使う

GitHubのgoogle/fontsリポジトリから、可変フォント3つを取ってきました。欧文用のNoto Sans(約2.0MB)、Noto Sans JP(約9.6MB)、Noto Sans KR(約10.4MB)です。どれもSIL Open Font License 1.1で、フォントファイルの販売や改名には制限がありますが、フォントで描いた画像の商用利用は自由です。フォントファイルはリポジトリの assets フォルダに置くだけで、サーバーには上げません。上げるのは描き終わったPNGだけです。

Pillow 11.3.0では、可変フォントも普通のTrueTypeとして読み込み、あとから名前付きインスタンスで太さを選びます。

def _font(lang, size, weight="Bold"):
    f = ImageFont.truetype(str(FONTS / FONT_FILE.get(lang, FONT_FILE["en"])), size)
    try:
        f.set_variation_by_name(weight)
    except Exception:
        pass
    return f

try/except はわざとです。そのフォントに指定した名前のインスタンスがない場合や、PillowのFreeTypeが可変フォントに対応していない場合に例外が出ます。カード1枚のためにビルド全体を止めるより、標準の太さで描く方がましだと判断しました。

言語ごとにフォントを1つ決めるのが、豆腐(□)を避けるいちばん簡単な方法です。Noto Sans JPは仮名と日本語の漢字をカバーし、欧文も含んでいるので、「iOS」や「AI」が混ざったタイトルでもフォールバック処理なしで描けます。

タイトルを収める:6段階のサイズと行送り1.32倍

タイトルの長さはまちまちです。最適なサイズを計算する代わりに、大きい順にサイズを試して、4行に収まった最初のものを使います。

max_w = W - pad * 2          # 1200 - 160
for size in (68, 62, 56, 50, 46, 42):
    tf = _font(lang, size, "Bold")
    lines = _wrap(d, title, tf, max_w, lang)
    if len(lines) <= 4:
        break
lines = lines[:4]
line_h = int(size * 1.32)

行送りはフォントサイズの1.32倍です。和文の字面は欧文の大文字より背が高いので、詰めすぎると行同士が窮屈に見えます。ブロックは縦方向に中央寄せしますが、カテゴリ名とぶつからないよう y=140 より上には置きません。なお42pxでも5行目が出るタイトルは lines[:4] で黙って切られます。今のタイトルでは起きていませんが、長いタイトルで流用するなら最初に確認すべき点です。

英語は空白で区切って draw.textlength で幅を測る普通のワードラップです。問題は空白のない日本語でした。

日本語の改行:おかしなカード3枚

横に並んだ3つの箱。第1版は1文字ずつ折り返し、カタカナ語「リンク」を「リン」と「ク」に分けた。第2版はカタカナの連なりを1語として扱うが、漢字語「本番」を「本」と「番」に分けた。第3版は助詞「に」の直後で改行し、「AIコーディングエージェントに」と「本番サーバーを」が別の行になった。
日本語の折り返し処理の3つの版と、それぞれが実際に出した改行。読み手の感覚に合ったのは最後の版だけ。

第1版:1文字ずつ折り返す。 最初は日本語を文字の並びとして扱い、次の1文字が入らなくなった所で改行していました。共有リンクの記事のテストカードはこうなりました。

iOSとAndroidで共通の共有リン
ク:流入元ごと…

「リンク」が「リン」と「ク」に割れています。日本語の読み手なら一瞬で気づく崩れ方です。

第2版:正規表現でトークンに分ける。 割ってはいけない単位をトークンにしました。英数字の単語(C# や Node.js も含む)、カタカナの連なり、それ以外は1文字ずつ。タイトルでは「テーマ:詳細」の形をよく使うので、全角コロンの後では必ず改行します。

tokens = re.findall(r"[A-Za-z0-9][A-Za-z0-9.+#_/'’-]*|[ァ-ヶー・]+|\s+|.", text)
…
if tok in (":", ":", "—") and cur.strip():
    lines.append(cur.rstrip())
    cur = ""

これで「リンク」は直りました。ところが次の記事のカードはこうです。

AIコーディングエージェントに本
番サーバー…

「本番」が割れました。漢字の熟語は文字列のどこにも境目の印がないので、トークナイザーには分かりません。

第3版:助詞の後で改行する。 ここで日本語の文法が助けになります。に・を・の・で・が・は・と・へ・も・や といった助詞や読点は、文節の終わりに来ます。その直後で改行すれば、たいてい自然に読めます。そこで、行があふれたら行内で最後の助詞か読点を探し、それが行の半分より後ろにあればそこで切ります。

if cur and draw.textlength(cur + tok, font=font) > max_w:
    cut = max((i + 1 for i, c in enumerate(cur) if c in "にをのでがはとへもや、,"), default=0)
    if cut >= len(cur) * 0.5 and cut < len(cur):
        lines.append(cur[:cut].rstrip())
        cur = cur[cut:].lstrip() + tok
    else:
        lines.append(cur.rstrip())
        cur = tok.lstrip()

「半分より後ろ」という条件は、行頭近くの助詞で切って、極端に短い行と長い行ができるのを防ぐためです。同じタイトルはこうなりました。

AIコーディングエージェントに
本番サーバーを触らせる:
効いたガードレール

最後に禁則処理。 読点・句点、閉じ括弧、長音符「ー」、小さい「ッ」「ょ」などは行頭に置かないのが和文組版の決まりです(行頭禁則)。折り返しが終わった後、行頭にこれらの文字があれば前の行の末尾に戻します。いわゆる「追い込み」です。

for i in range(1, len(lines)):
    while lines[i] and lines[i][0] in "、。,.)」』ーっゃゅょァィゥェォッャュョ":
        lines[i - 1] += lines[i][0]
        lines[i] = lines[i][1:]

正直に書いておくと、これは形態素解析ではなく経験則です。「はじめて」の「は」のように助詞と同じ文字が単語の中にあれば、単語の途中で切れることがありえますし、助詞のないタイトルはトークン単位の改行に戻ります。また、禁則文字のリストは小書きの「ぁぃぅぇぉ」を含んでおらず、完全ではありません。追い込んだ後の行幅も測り直していないので、右の余白80pxが1文字分のはみ出しを吸収する前提です。

本気でやるなら、GoogleのBudouXやMeCabなどの形態素解析で文節を切る方が確実です。使わなかった理由は2つで、Pillow・Jinja2・Markdown・PyYAMLで済んでいるビルドに依存を増やしたくなかったこと、そしてタイトルは短く、自分たちで書いて、カードを目で確認しているので、この程度で足りることです。ユーザー投稿のように目を通せないタイトルを扱うなら、迷わずBudouXを選びます。

キャッシュでビルドを軽く保つ

130枚以上のPNGを大きな和文フォントで毎回描き直すのは無駄です。タイトルはめったに変わりません。そこで、画像に影響する入力すべてのmd5をキーにします。

VERSION = "3"  # デザインを変えたら上げる → 全カードを描き直し

key = hashlib.md5("|".join([VERSION, title, label, lang, category, brand]).encode()).hexdigest()
cached = CACHE / f"{key}.png"          # .cache/og/<md5>.png
if cached.exists():
    shutil.copyfile(cached, out_path)
    return

入力が同じならファイルのコピーで終わります。VERSION は、キーに現れない変更(たとえば折り返しのコードそのもの)のための逃げ道です。上の3つの版を作る間に1→2→3と上げ、上げるたびに次のビルドで全カードが描き直されました。

結果は記事カード132枚(英語24、日本語24、韓国語84。noindexの韓国語旧記事を含む)とサイト共通のデフォルトカード1枚、計133枚で5.1MB、1枚あたり約38KBです。カード込みのサイト全体のビルドはMacで約6.0秒(user)/6.7秒(実時間)でした。このときはほとんどキャッシュからのコピーです。デプロイスクリプトはmd5で差分を見るので、変わっていないカードを再送することはありません。この変更の最初のデプロイでは、全HTMLのメタタグも変わったため、342ファイルを送りました。

メタタグへの組み込みと、render() の小さな落とし穴

テンプレートは常に画像を出力し、サイズも書きます。

<meta property="og:image" content="{{ og_image }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="{{ og_image }}">

twitter:card はこれまで summary で、小さな正方形のサムネイルでした。summary_large_image にすると、カードが横幅いっぱいに出ます。記事のJSON-LD(TechArticle)にも画像を入れます。

"image": {"@type": "ImageObject", "url": post["og_image"], "width": 1200, "height": 630}
if post.get("og_image") else None,

カテゴリ一覧やトップなど記事以外のページは /static/og/default.png を使います。そのために、全テンプレートに渡す共通の値に og_image を入れ、記事ページでは記事ごとの値を渡しました。最初のビルドはこれで落ちました。

TypeError: …render() got multiple values for keyword argument 'og_image'

ヘルパーはJinja2のテンプレートに render(**common, **ctx) と渡していました。辞書2つをキーワード引数に展開しても、マージはされません。両方にあるキーはエラーです。先にマージすれば、ページ側の値が勝ちます。

def render(tpl, rel, **ctx):
    …
    write(rel, jenv.get_template(tpl).render(**{**common, **ctx}))

確認したこと、まだ確認していないこと

生成したPNGを直接開いて、日本語と韓国語のタイトルの改行を全部目で見ました。デプロイ後、カードのURLにcurlすると 200 と image/png が返り、記事のHTMLの og:image にカードのURLが、twitter:card に summary_large_image が入っていることを確認しました。

FacebookのシェアデバッガーやXのカード検証、LINEのリンクプレビューでの表示はまだ試していません。どれもプレビューをキャッシュし、独自に切り抜くので、次に確認する項目です。日本の読者にはLINEでの見え方が特に大事です。

この方法が向いている場合

状況 選択
静的サイト、タイトルはあまり変わらない、記事は数百本まで ビルド時にPillowで描き、内容のハッシュでキャッシュ
数千ページ以上、またはユーザー入力のタイトル オンデマンドの画像生成エンドポイント+CDNキャッシュ
自分で書いて目で確認する日本語タイトル 本記事の助詞改行+禁則処理で十分
目を通せない日本語タイトル(自動生成・投稿) BudouXか形態素解析
英語・韓国語のタイトル 空白でのワードラップで十分

面白かったのはPillowの使い方ではなく、「テキストを折り返す」が言語ごとにまったく別の作業だったことです。日本語のテストケースは、自分たちの記事タイトルが1枚ずつおかしなカードとして教えてくれました。

よくある質問

Noto Sans JPで描いたOG画像を商用サイトで使っても大丈夫?

大丈夫です。Noto Sans・Noto Sans JP・Noto Sans KRはSIL Open Font License 1.1で公開されており、フォントで描いた画像の商用利用は認められています。制限があるのはフォントファイル自体の再配布や販売で、カードに入っているのは描画済みのピクセルだけです。

日本語の改行にBudouXや形態素解析を使わなかったのはなぜ?

そちらの方が確実です。ただ、ビルドに依存を増やしたくなかったことと、タイトルが短く手書きで、目で確認していることから、小さなヒューリスティックで済ませました。カタカナ語と英単語は割らない、はみ出したら助詞や読点の直後で改行する、行頭禁則の文字は前の行に戻す、の3つです。

og:imageを付けたらtwitter:cardも変える必要がある?

大きな画像で見せたいなら必要です。summaryのままだとXでは小さな正方形のサムネイルになります。summary_large_imageにしてtwitter:imageを付けると、1200×630のカードが横幅いっぱいに表示されます。

  • #Python
  • #Pillow
  • #OGP
  • #og:image
  • #フォント
  • #日本語
  • #禁則処理
  • #静的サイト

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑