reportlab 한글 PDF에서 글자가 소리 없이 사라질 때 — 글리프 커버리지 검사와 폰트 폴백

YONG JAE LEE·2026년 8월 25일
post-thumbnail

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입니다.


2. 왜 사라지는가

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로 가지 않습니다.
폭만 있고 아무것도 안 그려진 글리프로 갑니다.
네모는 폰트 안에 들어 있지만, 어떤 코드도 그걸 가리키지 않습니다.

「폰트에 글자가 없으면 네모가 뜬다」는 통념이 여기서 깨집니다.
네모는 만들어져 있는데, 도달할 수가 없습니다.

네모는 보입니다. 빈칸은 안 보입니다. 이 글이 다루는 건 후자입니다.


3. 진단 — 렌더링 전에 「없는 글자」를 목록으로 뽑습니다

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그래서
ā Ō없음있음다른 폰트로 바꿔 끼우면 된다
🔴없음없음폰트를 바꿔도 소용없다

해결책이 두 층이어야 하는 이유가 여기서 갈립니다.
폴백만으로도 안 되고, 치환만으로도 안 됩니다.


4. 해결 1 — 기호는 치환합니다

어느 폰트에도 없는 기호는 의미가 비슷한 글자로 바꿉니다.
⚠를 ▲로 바꿔도 「경고」라는 뜻은 살아남습니다.

SUBS = {
    "⚠": "▲",      # ⚠
    "✅": "✓",      # ✅
    "\U0001f534": "●",  # 🔴
    "️": "",       # variation selector
}

def substitute(text):
    for a, b in SUBS.items():
        text = text.replace(a, b)
    return text

마지막 줄이 실전에서 제일 자주 무는 함정입니다.

는 variation selector입니다. 폭이 0이라 눈에 안 보입니다.
이모지 뒤에 딸려 오는데, 에디터에서는 아무것도 안 보이니까
「이 줄은 멀쩡한데 왜 깨지지」 하고 한참 헤매게 됩니다.
치환표에 빈 문자열로 매핑해서 지워버리는 게 답입니다.


5. 해결 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)

reportlab의 Paragraph는 인라인 <font face="..."> 태그를 받습니다.
그래서 필요한 글자만 감싸주면 그 글자만 다른 폰트로 나옵니다.

마지막 주석이 설계 판단입니다.
어느 폰트에도 없으면 버립니다. 어차피 안 보일 거라면
「모르는 채로 사라지는 것」보다 「내가 알고 버리는 것」이 낫습니다.
진단 단계에서 목록으로 이미 봤으니까요.

두 층을 붙인 결과입니다.

=== 해결 후 남은 결손 ===
  '마오리어 지명: Whakatāne, Ōtautahi' → 버려진 글자: []
  '⚠ 경고  ✅ 완료  🔴 중요' → 버려진 글자: []

6. 함정 — 복원 목록이 곧 화이트리스트가 됩니다

여기가 제가 제일 오래 헤맨 곳입니다.

5절에서 <font face="FB"> 태그를 넣었는데, 문제가 하나 생깁니다.
원고 본문에 <> 가 들어 있으면 reportlab이 그걸 태그로 오해합니다.
그래서 escape를 해야 합니다. 그런데 escape하면 내가 방금 넣은 폰트 태그도 같이 죽습니다.

그래서 순서가 이렇게 됩니다.

  1. 폴백 대상을 눈에 안 띄는 마커로 감싼다 — <FBFONT>ā</FBFONT>
  2. esc()< > 를 전부 escape한다 → 마커도 &lt;FBFONT&gt;가 된다
  3. 마지막에 마커만 골라 진짜 태그로 되살린다
t = t.replace("&lt;FBFONT&gt;", f'<font face="{LATIN_FB}">')

동작합니다. 그런데 이 구조에는 함정이 있습니다.

되살리는 목록이 사실상 화이트리스트가 됩니다.
목록에 없는 것은 escape된 채로 남아 글자 그대로 찍힙니다.

실제로 물렸습니다. 원고 표 안에 줄바꿈을 넣으려고 <br/>을 직접 썼는데,
표 셀은 한 줄이라 마크다운의 「공백 두 칸 + 줄바꿈」을 쓸 수 없었거든요.
그런데 되살리는 목록에 <br/>이 없었습니다.

결과는 PDF에 <br/> 네 글자가 그대로 찍히는 거였습니다.
줄바꿈이 안 된 게 아니라, 태그가 글자가 된 겁니다.

t = re.sub(r"&lt;br\s*/?&gt;", "<br/>", t)

이 한 줄을 추가해서 고쳤습니다.

교훈은 「<br/>을 빠뜨리지 말자」가 아닙니다.
escape 후 복원 구조를 쓰기로 한 순간, 허용할 태그 목록을 명시적으로 관리해야 하는
책임이 생긴다
는 겁니다. 그 목록은 늘어나고, 늘어나는 걸 아무도 안 알려줍니다.


7. 정리

순서대로 놓으면 이렇습니다.

  1. 진단coverage()로 폰트가 아는 코드포인트를 뽑고, 없는 글자를 목록으로 확인
  2. 치환 — 어느 폰트에도 없는 기호는 비슷한 글자로. 보이지 않는 를 잊지 말 것
  3. 폴백 — 남은 건 글자 단위로 폰트를 바꿔 끼우고, 그래도 없으면 알고서 버린다
  4. 복원 — escape 후 되살릴 태그 목록을 명시적으로 관리

핵심은 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원입니다.

읽어주셔서 감사합니다.

profile
전방위아티스트

0개의 댓글