
reportlab으로 한글 PDF를 만들다 이걸 만났습니다.
Whakatāne → Whakat ne
Ōtautahi → tautahi
⚠ 경고 → 경고
한글은 멀쩡한데 특정 글자만 없어집니다. 네모도 아니고 그냥 빈칸입니다.
폰트 등록이 잘못된 게 아닙니다. 그 폰트에 그 글자가 없는 겁니다.
그리고 reportlab은 없는 글자를 만나도 예외를 던지지 않습니다.
경고도 로그도 없습니다. 그냥 지우고 넘어갑니다.
네모라도 찍혔으면 다행이었을 겁니다. 눈에 띄니까요.
저는 지명에서 글자 하나가 빠진 걸 PDF를 몇 번 다시 뽑고 나서야 알았습니다.
환경: macOS 15.7.8 · reportlab 4.1.0 · fontTools 4.60.1.
본문 폰트는 한글 TTF 하나(KR-Regular.ttf), 폴백은 macOS 기본 Arial입니다.
TTF에는 cmap 테이블이 있습니다. 「이 코드포인트는 저 글리프」를 적어둔 표입니다.
한글 폰트는 대개 한글과 기본 라틴만 담습니다.
매크론(ā), 기호(⚠), 이모지는 없는 게 오히려 정상입니다.
여기까지는 예상 가능한 이야기입니다. 그런데 한 겹 더 있습니다.
PDF의 텍스트 레이어를 뜯어보면 사라진 글자가 이렇게 남아 있습니다.
'마오리어 지명: Whakat\x00ne, \x00tautahi'
'\x00 경고 \x00 완료 \x00 중요'
\x00 — 글리프 인덱스 0, 즉 .notdef 입니다.
문자가 없어진 게 아니라 「없음」이라는 글리프로 바뀐 겁니다.
.notdef는 보통 네모로 그려집니다. 그럼 네모가 나와야 하는데 왜 안 나올까요.
폰트가 비어 있는 걸까요? 열어봤습니다.
[KR-Regular] .notdef 윤곽선 개수(numberOfContours): 5 → 그려짐
네모가 멀쩡히 그려져 있습니다. 그런데 화면엔 안 나옵니다.
여기서 PDF에 실제로 박힌 폰트를 꺼내 봐야 합니다.
reportlab은 폰트를 통째로 넣지 않고 쓰인 글리프만 골라 서브셋을 만들거든요.
서브셋 글리프 수: 119
.notdef 윤곽선 5 폭 865 → 그려짐
glyph00001 윤곽선 0 폭 263 → 비어 있음
.notdef는 서브셋 안에도 네모째로 살아 있습니다. 그런데 cmap을 보면 —
서브셋 cmap (platformID=1, platEncID=0)
코드 0 → 'glyph00001'
코드 0이 .notdef가 아니라 glyph00001을 가리킵니다.
그리고 glyph00001은 윤곽선이 0개, 폭만 263인 글리프입니다. 사실상 공백입니다.
정리하면 이렇습니다.
없는 글자는
.notdef로 가지 않습니다.
폭만 있고 아무것도 안 그려진 글리프로 갑니다.
네모는 폰트 안에 들어 있지만, 어떤 코드도 그걸 가리키지 않습니다.
「폰트에 글자가 없으면 네모가 뜬다」는 통념이 여기서 깨집니다.
네모는 만들어져 있는데, 도달할 수가 없습니다.
네모는 보입니다. 빈칸은 안 보입니다. 이 글이 다루는 건 후자입니다.
PDF를 뽑아놓고 눈으로 찾으면 늦습니다. 애초에 눈에 안 띄니까요.
렌더링하기 전에 폰트가 무엇을 아는지 물어보면 됩니다.
from fontTools.ttLib import TTFont as FTFont
def coverage(path):
"""폰트 cmap에 실제로 들어 있는 코드포인트 집합"""
f = FTFont(path)
cps = set()
for t in f["cmap"].tables:
cps |= set(t.cmap.keys())
return cps
def missing(text, cov):
return sorted({ch for ch in text if ord(ch) not in cov and ch not in "\n\t"})
돌려보면 이렇게 나옵니다.
'마오리어 지명: Whakatāne, Ōtautahi'
→ 없는 글자: ['ā', 'Ō'] (Arial에는? ['ā', 'Ō'])
'⚠ 경고 ✅ 완료 🔴 중요'
→ 없는 글자: ['⚠', '✅', '🔴'] (Arial에는? [])
이 출력에 답이 다 들어 있습니다.
| 글자 | 한글 폰트 | Arial | 그래서 |
|---|---|---|---|
ā Ō | 없음 | 있음 | 다른 폰트로 바꿔 끼우면 된다 |
⚠ ✅ 🔴 | 없음 | 없음 | 폰트를 바꿔도 소용없다 |
해결책이 두 층이어야 하는 이유가 여기서 갈립니다.
폴백만으로도 안 되고, 치환만으로도 안 됩니다.
어느 폰트에도 없는 기호는 의미가 비슷한 글자로 바꿉니다.
⚠를 ▲로 바꿔도 「경고」라는 뜻은 살아남습니다.
SUBS = {
"⚠": "▲", # ⚠
"✅": "✓", # ✅
"\U0001f534": "●", # 🔴
"️": "", # variation selector
}
def substitute(text):
for a, b in SUBS.items():
text = text.replace(a, b)
return text
마지막 줄이 실전에서 제일 자주 무는 함정입니다.
️는 variation selector입니다. 폭이 0이라 눈에 안 보입니다.
이모지 뒤에 딸려 오는데, 에디터에서는 아무것도 안 보이니까
「이 줄은 멀쩡한데 왜 깨지지」 하고 한참 헤매게 됩니다.
치환표에 빈 문자열로 매핑해서 지워버리는 게 답입니다.
문서 전체에 폰트를 하나 지정하는 게 아니라, 글자 하나하나에 대해 정합니다.
def fallback(text, kr_cov, fb_cov):
out = []
for ch in text:
if ord(ch) in kr_cov or ch in "\n\t":
out.append(ch)
elif ord(ch) in fb_cov:
out.append(f'<font face="FB">{ch}</font>')
# 어느 폰트에도 없으면 버린다 — 조용히 사라지느니 내가 버린다
return "".join(out)
reportlab의 Paragraph는 인라인 <font face="..."> 태그를 받습니다.
그래서 필요한 글자만 감싸주면 그 글자만 다른 폰트로 나옵니다.
마지막 주석이 설계 판단입니다.
어느 폰트에도 없으면 버립니다. 어차피 안 보일 거라면
「모르는 채로 사라지는 것」보다 「내가 알고 버리는 것」이 낫습니다.
진단 단계에서 목록으로 이미 봤으니까요.
두 층을 붙인 결과입니다.
=== 해결 후 남은 결손 ===
'마오리어 지명: Whakatāne, Ōtautahi' → 버려진 글자: []
'⚠ 경고 ✅ 완료 🔴 중요' → 버려진 글자: []
여기가 제가 제일 오래 헤맨 곳입니다.
5절에서 <font face="FB"> 태그를 넣었는데, 문제가 하나 생깁니다.
원고 본문에 < 나 > 가 들어 있으면 reportlab이 그걸 태그로 오해합니다.
그래서 escape를 해야 합니다. 그런데 escape하면 내가 방금 넣은 폰트 태그도 같이 죽습니다.
그래서 순서가 이렇게 됩니다.
<FBFONT>ā</FBFONT>esc()로 < > 를 전부 escape한다 → 마커도 <FBFONT>가 된다t = t.replace("<FBFONT>", f'<font face="{LATIN_FB}">')
동작합니다. 그런데 이 구조에는 함정이 있습니다.
되살리는 목록이 사실상 화이트리스트가 됩니다.
목록에 없는 것은 escape된 채로 남아 글자 그대로 찍힙니다.
실제로 물렸습니다. 원고 표 안에 줄바꿈을 넣으려고 <br/>을 직접 썼는데,
표 셀은 한 줄이라 마크다운의 「공백 두 칸 + 줄바꿈」을 쓸 수 없었거든요.
그런데 되살리는 목록에 <br/>이 없었습니다.
결과는 PDF에 <br/> 네 글자가 그대로 찍히는 거였습니다.
줄바꿈이 안 된 게 아니라, 태그가 글자가 된 겁니다.
t = re.sub(r"<br\s*/?>", "<br/>", t)
이 한 줄을 추가해서 고쳤습니다.
교훈은 「<br/>을 빠뜨리지 말자」가 아닙니다.
escape 후 복원 구조를 쓰기로 한 순간, 허용할 태그 목록을 명시적으로 관리해야 하는
책임이 생긴다는 겁니다. 그 목록은 늘어나고, 늘어나는 걸 아무도 안 알려줍니다.
순서대로 놓으면 이렇습니다.
coverage()로 폰트가 아는 코드포인트를 뽑고, 없는 글자를 목록으로 확인️를 잊지 말 것핵심은 3절입니다. 렌더링 후에 눈으로 찾지 말고, 렌더링 전에 목록으로 뽑으세요.
네모라면 눈으로도 찾습니다. 빈칸은 못 찾습니다.
전체가 도는 최소 예제를 아래에 둡니다. 폰트 경로만 바꾸면 그대로 돌아갑니다.
macOS 기준입니다 — 폴백 폰트 경로는 OS마다 다릅니다.
# -*- coding: utf-8 -*-
"""한글 PDF 글리프 문제 최소 재현 예제 (macOS 기준).
python3 glyph_demo.py → out/before.pdf, out/after.pdf"""
import os
from fontTools.ttLib import TTFont as FTFont
from reportlab.lib.pagesizes import A5
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
# ── 폰트 경로: 본인 환경에 맞게 두 줄만 바꾸면 됩니다 ──
KR = os.environ.get("KR_FONT", "./KR-Regular.ttf") # 본문 한글 폰트
ARIAL = os.environ.get("FB_FONT", # 폴백 (macOS 기본)
"/System/Library/Fonts/Supplemental/Arial.ttf")
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "out")
os.makedirs(OUT, exist_ok=True)
pdfmetrics.registerFont(TTFont("KR", KR))
pdfmetrics.registerFont(TTFont("FB", ARIAL))
# ── 진단: 폰트가 실제로 아는 코드포인트 집합 ──
def coverage(path):
f = FTFont(path)
cps = set()
for t in f["cmap"].tables:
cps |= set(t.cmap.keys())
return cps
KR_COV = coverage(KR)
FB_COV = coverage(ARIAL)
def missing(text, cov):
return sorted({ch for ch in text if ord(ch) not in cov and ch not in "\n\t"})
# ── 해결 1: 폰트에 없는 기호를 있는 기호로 치환 ──
SUBS = {
"\u26a0": "▲", # ⚠
"\u2705": "✓", # ✅
"\U0001f534": "●", # 🔴
"\ufe0f": "", # variation selector — 눈에 안 보이는데 깨진다
}
def substitute(text):
for a, b in SUBS.items():
text = text.replace(a, b)
return text
# ── 해결 2: 문자 단위 폰트 폴백 ──
def fallback(text, kr_cov, fb_cov):
out = []
for ch in text:
if ord(ch) in kr_cov or ch in "\n\t":
out.append(ch)
elif ord(ch) in fb_cov:
out.append(f'<font face="FB">{ch}</font>')
# 어느 폰트에도 없으면 버린다 — 조용히 사라지느니 내가 버린다
return "".join(out)
def fix(s):
return fallback(substitute(s), KR_COV, FB_COV)
SAMPLES = [
"정상 한글은 잘 나옵니다.",
"마오리어 지명: Whakatāne, Ōtautahi",
"⚠ 경고 ✅ 완료 🔴 중요",
]
def render(path, transform):
st = ParagraphStyle("body", fontName="KR", fontSize=12, leading=20)
doc = SimpleDocTemplate(path, pagesize=A5)
flow = []
for s in SAMPLES:
flow.append(Paragraph(transform(s), st))
flow.append(Spacer(1, 8))
doc.build(flow)
print("wrote", path)
if __name__ == "__main__":
print("=== 진단: 한글 폰트에 없는 글자 ===")
for s in SAMPLES:
m = missing(s, KR_COV)
print(f" {s!r}")
print(f" → 없는 글자: {m} (Arial에는? {[c for c in m if ord(c) in FB_COV]})")
render(os.path.join(OUT, "before.pdf"), lambda s: s)
render(os.path.join(OUT, "after.pdf"), fix)
print("\n=== 해결 후 남은 결손 ===")
for s in SAMPLES:
after = substitute(s)
left = [c for c in after if ord(c) not in KR_COV and ord(c) not in FB_COV and c not in "\n\t"]
print(f" {s!r} → 버려진 글자: {left}")
KR_FONT=./내폰트.ttf python3 glyph_demo.py
이 글은 시리즈 「AI로 책 만들어 팔기」의 일부입니다.
1편에서는 이 파이프라인으로 만든 책이 왜 한 권도 안 팔렸는지를 적었습니다 —
AI로 책 2권을 만들어 상품 42개를 올렸습니다. 매출은 0원입니다.
읽어주셔서 감사합니다.