전체 글

gpdf에서 일본어 폰트를 임베드하려면?

TTF 바이트를 gpdf.WithFont에 전달하면 끝. 서브셋 임베딩은 자동, CGO도 필요 없음. Go에서 일본어 PDF를 만드는 최단 경로.

저자: gpdf team

질문을 다시 말하면

AddUTF8Font 절차, CGO 의존, 문서마다 5 MB짜리 폰트 전부 임베딩 — 이런 것들 없이 gpdf로 일본어(또는 CJK) PDF를 만드는 가장 짧은 방법은 무엇인가.

요점

TTF 바이트를 읽고, gpdf.WithFont("NotoSansJP", fontBytes)NewDocument에 전달하고, 필요하면 기본 폰트로 지정한다. 설정 3줄이면 gpdf가 실제로 사용된 글리프만 자동 서브셋 임베딩한다 — 5 MB 통째로 끌어안지 않는다.

완전한 예제

package main

import (
    "log"
    "os"

    "github.com/gpdf-dev/gpdf"
    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    font, err := os.ReadFile("NotoSansJP-Regular.ttf")
    if err != nil {
        log.Fatal(err)
    }

    doc := gpdf.NewDocument(
        gpdf.WithPageSize(gpdf.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
        gpdf.WithFont("NotoSansJP", font),
        gpdf.WithDefaultFont("NotoSansJP", 12),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("こんにちは、世界。", template.FontSize(24), template.Bold())
            c.Text("日本語 PDF、これだけ。")
        })
    })

    data, err := doc.Generate()
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("hello.pdf", data, 0o644); err != nil {
        log.Fatal(err)
    }
}

Google Fonts에서 NotoSansJP-Regular.ttf를 다운로드해 main.go 옆에 두고 go run main.go를 실행하면 일본어가 찍힌 한 장짜리 PDF가 나온다.

세 줄 뒤에서 벌어지는 일

내부에서는 두 가지 일이 일어나며, 둘 다 사용자가 신경 쓸 필요가 없다.

서브셋 임베딩. Noto Sans JP은 약 17,000개의 글리프를 담고 있고 Regular 단독으로 약 5 MB다. 폰트 전체를 그대로 임베드한다면 네 줄짜리 영수증 PDF도 5 MB를 넘어버린다. gpdf는 렌더링한 텍스트를 훑어 실제 사용된 글리프 ID만 뽑아 PDF에 기록한다. 짧은 청구서 한 장이라면 폰트 데이터는 보통 20–40 KB 수준이다.

gofpdf도 서브셋화는 가능했지만, AddUTF8Font가 파일 경로와 UTF-8 플래그를 받아 커서 이동 중에 로드하는 구조라 문서 중간에 폰트를 바꾸는 것이 번거로웠다. gpdf는 문서 생성 시 한 번만 등록하고, 이후 모든 c.Text는 패밀리 이름만 참조한다. 호출마다 준비 작업은 없다.

CGO를 쓰지 않는다. 이 점은 생각보다 크다. 다른 생태계에서는 폰트 처리가 FreeType이나 HarfBuzz를 거치는 일이 많은데, 그러면 C 의존이 생기고, 빌드 캐시 동작이 달라지며, Docker 이미지 레이어가 늘고, macOS에서 linux/arm64로 크로스 컴파일할 때 추가 설정이 필요해진다. gpdf는 TrueType 테이블을 순수 Go로 파싱한다. go build는 여전히 정적 바이너리를 만들고, distroless 컨테이너에 Go 바이너리와 TTF만 넣어 배포할 수 있다.

Bold / Italic 변형

일본어 Noto 패밀리는 굵기별로 파일이 나뉘어 있다. 굵은 글씨를 쓰려면 Bold TTF를 -Bold 접미사로 따로 등록한다:

reg, _ := os.ReadFile("NotoSansJP-Regular.ttf")
bold, _ := os.ReadFile("NotoSansJP-Bold.ttf")

doc := gpdf.NewDocument(
    gpdf.WithFont("NotoSansJP", reg),
    gpdf.WithFont("NotoSansJP-Bold", bold),
    gpdf.WithDefaultFont("NotoSansJP", 12),
)

이제 template.Bold()-Bold 변형을 집어든다. -Italic-BoldItalic도 같은 규약이다. 변형을 등록하지 않으면 합성 굵기로 폴백된다 — 화면상 읽히지만 타이포그래피적으로는 정확하지 않다. 실전 청구서라면 실제 굵기를 등록하자.

같은 문서에 한·중·일 섞기

패밀리는 몇 개든 등록 가능하다. gpdf는 이들을 독립적으로 관리한다. 텍스트 단위로 template.FontFamily(...)를 이용해 전환하면 된다:

jp, _ := os.ReadFile("NotoSansJP-Regular.ttf")
sc, _ := os.ReadFile("NotoSansSC-Regular.ttf")
kr, _ := os.ReadFile("NotoSansKR-Regular.ttf")

doc := gpdf.NewDocument(
    gpdf.WithFont("NotoSansJP", jp),
    gpdf.WithFont("NotoSansSC", sc),
    gpdf.WithFont("NotoSansKR", kr),
    gpdf.WithDefaultFont("NotoSansJP", 12),
)

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(4, func(c *template.ColBuilder) {
        c.Text("日本語")
    })
    r.Col(4, func(c *template.ColBuilder) {
        c.Text("中文", template.FontFamily("NotoSansSC"))
    })
    r.Col(4, func(c *template.ColBuilder) {
        c.Text("한국어", template.FontFamily("NotoSansKR"))
    })
})

한자 통합(Han unification) 때문에 일본어와 중국어 간체는 유니코드 코드포인트를 공유하지만 실제 자형은 다르다. 같은 코드포인트라도 어떤 폰트를 쓰느냐에 따라 글자 모양이 달라진다 — 폰트 선택은 미학 문제가 아니라 정확성 문제다. 국가별 세금계산서나 운송장을 생성한다면 두 폰트 모두 등록해야 한다.

두부 문자 함정

일본어를 썼는데 WithFont를 빠뜨리면 gpdf는 Base-14 표준 PDF 폰트로 폴백한다. 거기엔 CJK 글리프가 없으므로 문자가 빈 사각형으로 렌더링된다. 유니코드 쪽에서 흔히 말하는 "두부 문자"다:

□□□□□、□□。

이 출력을 보면 원인은 하나다: CJK 폰트를 등록하지 않았거나, 해당 글리프가 없는 패밀리로 쓰고 있다. 해결책도 하나다: WithFont를 추가하고 WithDefaultFont로 기본값을 잡거나 c.Texttemplate.FontFamily를 명시하자.

관련 글

gpdf 써보기

gpdf는 Go용 PDF 생성 라이브러리다. MIT, 외부 의존 없음, CJK 기본 지원.

go get github.com/gpdf-dev/gpdf

⭐ GitHub에서 Star · 문서 읽기