Pillow로 OG 이미지 카드 자동 생성: 한·일 제목 줄바꿈 처리
글마다 1200×630 og:image 를 빌드할 때 Pillow와 Noto 글꼴로 만든 기록. 제목 크기 맞추기, 일본어 줄바꿈 실패 세 번, 캐시, 메타 태그 연결까지.
이번 주까지 이 블로그 글에는 og:image 가 하나도 없었습니다. 링크를 공유하면 글자뿐인 미리보기가 떴고, 글의 JSON-LD 에도 image 가 비어 있었습니다. 이 글은 정적 사이트에서 글마다 대표 카드 이미지를 자동으로 만들고 싶은 분, 특히 한국어·일본어 제목을 다루는 분을 위한 기록입니다. 직접 만든 정적 사이트 생성기에 Pillow 카드 생성을 붙인 과정과, 일본어 제목이 제자리에서 줄바꿈되기까지 나온 잘못된 카드 세 장을 정리합니다.
카드에 들어가는 것과 빌드할 때 그리는 이유
모든 글에 1200×630 PNG를 한 장씩, 빌드 중에 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 저장소에서 가변 글꼴 세 개를 받았습니다. 라틴 문자용 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 이 가변 글꼴을 지원하지 않으면 예외가 납니다. 카드 한 장 때문에 빌드 전체를 멈추느니 기본 굵기로 그리는 편이 낫습니다.
언어마다 글꼴을 하나씩 정해 두는 것이 두부(□) 글자를 피하는 가장 쉬운 방법입니다. Noto Sans KR 은 한글과 라틴 문자를 모두 담고 있어서, "Flutter"나 "iOS"가 섞인 제목도 대체 글꼴 처리 없이 그려집니다.
제목 맞추기: 크기 여섯 단계, 줄 간격 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 로 폭을 재서, 넘치면 다음 줄로 보냅니다.
if lang == "en" or (lang == "ko" and " " in text):
words, lines, cur = text.split(" "), [], ""
for w in words:
t = (cur + " " + w).strip()
if draw.textlength(t, font=font) <= max_w:
cur = t
else:
if cur:
lines.append(cur)
cur = w
…
공백이 없는 한국어 제목만 일본어와 같은 토큰 처리로 넘어갑니다. 문제는 띄어쓰기가 없는 일본어였습니다.
일본어 줄바꿈: 잘못된 카드 세 장
1판: 한 글자씩 줄바꿈. 처음에는 일본어를 글자의 나열로 보고, 다음 글자가 안 들어가는 자리에서 줄을 바꿨습니다. 공유 링크 글의 시험 카드는 이렇게 나왔습니다.
iOSとAndroidで共通の共有リン
ク:流入元ごと…
가타카나 단어 リンク(링크)가 リン 과 ク 로 쪼개졌습니다. 한국어로 치면 "링"과 "크"가 다른 줄로 간 셈이라, 일본어 독자는 바로 알아챕니다.
2판: 정규식 토큰. 쪼개면 안 되는 단위를 토큰으로 묶었습니다. 영숫자 단어(C#, Node.js 포함), 가타카나 덩어리, 나머지는 한 글자씩입니다. 제목에 "주제:세부" 형태를 자주 쓰므로 전각 콜론 뒤에서는 무조건 줄을 바꿉니다.
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 이 한 글자 넘침을 받아 준다는 전제입니다.
제대로 하려면 Google 의 BudouX 나 MeCab 같은 형태소 분석기로 문절을 나누는 편이 확실합니다. 쓰지 않은 이유는 두 가지입니다. 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 은 키에 드러나지 않는 변경, 예를 들어 줄바꿈 코드 자체를 바꿨을 때를 위한 장치입니다. 위의 세 판을 거치며 1 → 2 → 3 으로 올렸고, 올릴 때마다 다음 빌드에서 모든 카드가 다시 그려졌습니다.
결과는 글 카드 132장(영어 24, 일본어 24, 한국어 84. 아직 noindex 인 한국어 옛 글 포함)과 사이트 기본 카드 1장, 합계 133장에 5.1MB, 한 장에 약 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) 로 넘기고 있었습니다. 딕셔너리 두 개를 키워드 인자로 펼치면 합쳐지는 게 아니라, 양쪽에 있는 키는 오류가 됩니다. 먼저 합치면 페이지 쪽 값이 이깁니다.
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 링크 미리보기에서의 모습은 아직 확인하지 않았습니다. 이 서비스들은 미리보기를 캐시하고 나름대로 잘라 보여 주므로 다음 확인 항목입니다.
이 방식이 맞는 경우
| 상황 | 선택 |
|---|---|
| 정적 사이트, 제목이 잘 안 바뀜, 글 수백 개까지 | 빌드할 때 Pillow 로 그리고 내용 해시로 캐시 |
| 수천 페이지 이상이거나 사용자 입력 제목 | 요청 시 이미지를 만드는 엔드포인트 + CDN 캐시 |
| 한국어·영어 제목 | 띄어쓰기 기준 단어 줄바꿈으로 충분 |
| 직접 쓰고 눈으로 확인하는 일본어 제목 | 이 글의 조사 기준 줄바꿈 + 금칙 처리 |
| 볼 수 없는 일본어 제목 (자동 생성·사용자 입력) | BudouX 나 형태소 분석기 |
흥미로웠던 건 Pillow 사용법이 아니라, "글자를 줄바꿈한다"가 언어마다 전혀 다른 일이라는 점이었습니다. 일본어 시험 사례는 우리 글 제목들이 잘못된 카드 한 장씩으로 알려 줬습니다.
자주 묻는 질문
Noto Sans KR로 그린 OG 이미지를 상업 사이트에 써도 되나요?
됩니다. Noto Sans, Noto Sans JP, Noto Sans KR은 SIL Open Font License 1.1로 공개돼 있고, 글꼴로 그린 이미지의 상업적 사용을 허용합니다. 제한은 글꼴 파일 자체를 재배포하거나 판매하는 쪽에 있고, 카드에는 그려진 픽셀만 들어 있습니다.
한국어 제목 줄바꿈은 어떻게 하나요?
한국어는 띄어쓰기 단위로 줄을 바꾸므로, 제목에 공백이 있으면 영어와 같은 단어 단위 줄바꿈을 씁니다. draw.textlength 로 폭을 재고, 넘치면 다음 줄로 넘깁니다. 공백이 없는 제목만 일본어와 같은 토큰 단위 처리로 갑니다.
og:image 를 넣으면 twitter:card 도 바꿔야 하나요?
큰 이미지로 보이게 하려면 바꿔야 합니다. summary 그대로면 X에서 작은 정사각형 썸네일로 나옵니다. summary_large_image 로 바꾸고 twitter:image 를 넣으면 1200×630 카드가 가로 전체로 보입니다.