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"])
フォント: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枚
第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のカードが横幅いっぱいに表示されます。