記事一覧

Go で日本語 PDF を作る決定版ガイド (2026)

Go で日本語 PDF を吐く完全手順。CGO なし、Chromium なし、豆腐文字なし。フォント・サブセット・混植・縦書きの実務まで。

TL;DR

Go の PDF に こんにちは と書いたら豆腐 □□□□□ が 5 つ並んだ、という症状の直し方はリライトではなくセットアップ 2 行。TTF を読んで gpdf.WithFontNewDocument に渡し、あとは日本語を書く。gpdf はグリフテーブルを自動的にサブセット化するので、出力には実際に使った文字のグリフだけが載る — 5 MB のフルフォントではなく 30 KB 前後。この記事はその全景図: なぜ Go で日本語 PDF は妙に難しかったのか、2026 年の現実的な選択肢 4 つ、動くコード、フォントサブセット化の内部、混植の実務、そしてまだ解けていない部分

なぜこの記事を書くのか

Go で日本語 PDF を吐くのは本来 5 分の仕事だ。多くのチームではそれが 1 日半かかっている。

よくあるのはこういう流れ: AddUTF8Font を呼び出してみる → 出力 PDF には空の四角 (豆腐) が並ぶ → シニアエンジニアが半日使って、フォントパスなのかサブセットフラグなのか CMap なのか UTF-8 フラグなのか PDF ビューアなのかを絞り込む。夕方には Slack に「なぜ漢字がまだ壊れてるのか」というスレッドが立ち、翌日には誰もが後悔するヘルパー関数が 3 つ追加された PR が出ている。

根本原因はそのどれでもない。Go で一番長く生きている PDF ライブラリが、2002 年の PHP と Latin-1 前提で設計されたことと、それ以降に書かれた日本語チュートリアルのほぼ全てがその負債と戦ってきたこと。この記事は 2026 年版、ゼロからやる場合に本当に動くやり方と、今なお難しい部分を正直に書く。

本記事のコードは gpdf v1.x (2026-04 時点) で動作確認済み。ベンチ数値は Apple M1 + Go 1.25。

豆腐文字問題を 90 秒で

PDF は Unicode を知らない。PDF が知っているのは グリフ ID — フォントに埋め込まれたグリフテーブルへの整数インデックス。"こんにちは" を PDF に書くには、誰かが以下を全部やる必要がある:

  1. TTF を解析 して、各コードポイントに対応するグリフ ID を cmap サブテーブルから引く。
  2. ToUnicode CMap を書き出す — ユーザーがコピー・検索したときに、グリフからテキストへ戻せるように。
  3. サブセット化。Noto Sans JP の 2 万グリフを全部埋め込まない。
  4. 埋め込みname / OS/2 / head テーブルとエンコーディングオブジェクトを正しく接続した形で。

このどれかが抜けるか間違うと、PDF ビューアはコードポイントに対応するグリフを見つけられず豆腐を描く。アーカイブ済みの jung-kurt/gofpdfgo-pdf/fpdf 系統は、これら全てを 単一バイトフォント前提の内部モデル に後付けしてきた — 2002 年のオリジナル FPDF は Latin-1 しか知らなかったからだ。セットアップが脆いのも、出力がサブセットではなくフルフォントを埋め込みがちなのも、OS や PDF ビューアによって壊れ方が変わるのもそのせい。

gpdf は CJK をファーストクラスの用例として扱う。TTF サブセッタはコアパッケージに同梱。ToUnicode CMap は自動で書かれる。単一バイトフォントの過去互換層がないので、AddUTF8Font ダンスもない。

2026 年の現実的な選択肢 4 つ

コードを書く前に、正直な勢力図。「日本語対応」は「正しい TTF を渡せば豆腐もクラッシュもなしに任意の日本語を描ける」の意味で使う。

選択肢ライセンス依存CJK 経路300 字 PDF のサイズ備考
go-pdf/fpdf (2025 archived)MITstdlibAddUTF8Font 後付け約 5 MB (フル埋込)Latin-1 コアに後付け。サブセットはオプトインかつ不完全。
signintech/gopdfMITstdlibAddTTFFont + 手動約 3 MB低レベル。座標を自分で書く。サブセットはあるが自分で叩く。
chromedp + ChromiumMIT + ChromeChromium バイナリブラウザ経由 (ネイティブ)可変HTML/CSS。コンテナにフォントを入れる必要あり。イメージ 500 MB+。
gpdfMITstdlib のみネイティブ、自動サブセット約 30 KB純 Go。ビルダー API。ToUnicode CMap を自動出力。

2 点強調したい。

「フル埋込」と「自動サブセット」の 160 倍の差は誤差ではない。 10 明細の EC 請求書 PDF で使う日本語グリフは、ユニークで多くても 120 字程度。毎回の請求書にフル Noto Sans JP (5.1 MB) を埋め込むなら、年末までに同じ 5 MB のグリフデータがオブジェクトストレージに 1,000 万回コピーされる。サブセット埋込なら、使ったグリフだけが乗る。

「chromedp で動く」は事実だが、最も高価な答え。すでにスクリーンショット用にヘッドレス Chrome の艦隊を運用しているチームなら、それに PDF を相乗りさせるのはアリ。そうでないチームが 日本語を印字するためだけに Chromium を立てるのは、40 行の Go で解ける問題に対して過剰なインフラだ。

動く最短経路

まずこれを試す。完全形 — コピーして main.go で保存、TTF 2 本を隣に置いて go run main.go

package main

import (
    "log"
    "os"

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

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

    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
        gpdf.WithFont("NotoSansJP", regular),
        gpdf.WithFont("NotoSansJP-Bold", bold),
        gpdf.WithDefaultFont("NotoSansJP", 11),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("請求書", template.FontFamily("NotoSansJP-Bold"), template.FontSize(22))
            c.Text("2026 年 4 月 16 日")
        })
    })
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(7, func(c *template.ColBuilder) {
            c.Text("株式会社 ABC 御中", template.FontSize(13))
            c.Text("〒 100-0001 東京都千代田区千代田 1-1")
        })
        r.Col(5, func(c *template.ColBuilder) {
            c.Text("合計 ¥ 128,000", template.FontFamily("NotoSansJP-Bold"), template.AlignRight())
            c.Text("支払期限: 2026-05-31", template.AlignRight())
        })
    })

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

全部説明するより、目に留まってほしい点だけ:

  • AddUTF8Font もなし、UTF-8 フラグもなし、Text にフォントパス引数もなしgpdf.WithFont で family を登録、c.Text は Unicode を書くだけ。配線は内部で完結。
  • 太字は別 family、フラグではない。これは TTF の流通形態 (Noto Sans JP Regular と Noto Sans JP Bold は name テーブルが別の独立ファイル) に合っている。ゴシックと明朝、Source Han Sans JP Normal と Heavy なども同じパターン。
  • レイアウトはグリッド、カーソルではないr.Col(7, ...)r.Col(5, ...) は合計 12。幅は宣言的、x 座標は書かない。詳細は gpdf の 12 カラムグリッドの仕組み
  • AlignRight() はロケール非依存。「¥ 128,000」は「$1,280.00」と同じ書き方で右寄せできる。テキストの中身でレイアウトコードが変わらない。

できた invoice-ja.pdf を任意のビューアで開き、「株式会社 ABC 御中」を選択してテキストエディタに貼る。株式会社 ABC 御中 と出る — 文字化けしない。これが ToUnicode CMap の仕事で、gpdf は既定で書き出す。

フォントサブセット化 — 隠れたサイズ爆弾

チュートリアルが飛ばしがちな、CJK in PDF の最重要性質サブセット埋込

TTF はグリフアウトラインとメタデータテーブルの集合。Noto Sans JP Regular は約 17,500 グリフを含み 5.1 MB。典型的な請求書が使う日本語ユニーク文字は 60〜200 字。毎文書にフル埋込は桁単位の浪費だ。

サブセット埋込は使ったグリフだけを残す。gpdf はこれを自動でやる。上のコード例を動かして確認:

$ ls -l invoice-ja.pdf
-rw-r--r--  1 dev  staff  34892 Apr 16 10:12 invoice-ja.pdf

34 KB。比較: 同じ文書を go-pdf/fpdf + AddUTF8Font("NotoSansJP", "NotoSansJP-Regular.ttf", true) (第 3 引数は UTF-8 フラグ) で吐くと 4.9 MB。入力も出力文字も同じ、ファイルサイズは 143 倍。原因は fpdf の経路が emit 時点でサブセット化せずフォントテーブル全体を埋め込むため。

実運用への影響を具体的に:

  • 秒 10 件の請求書生成 (SaaS でよくある規模) で、サブセット差は 0.3 MB/s と 43 MB/s の帯域差。ロードバランサはこれに意見を持っている。
  • コールドストレージ費用は PDF サイズに線形。アーカイブ 500 万件 × 5 MB = 25 TB。× 30 KB = 150 GB。オブジェクトストレージ料金は月額で 4 桁 vs 2 桁のレベルで変わる。
  • メール添付 は各社 10〜25 MB 上限。5 MB の日本語請求書 + 他の添付 + MIME エンコーディングで、普通にその天井にぶつかる。

gpdf はレンダリング時にサブセット化する。オンにするフラグはない。どのグリフが埋め込まれたかは gpdf の検証ツールで見られるが、要点はこう — を使ったなら、その 4 グリフだけが出力に乗り、残り 17,496 は乗らない。

混植 — 漢字 + かな + ASCII を 1 行に

日本語テキストは単独で現れることは稀だ。実運用の 1 行はこういう形をしている:

API の P95 レイテンシは 50 ms 未満です。

5 スクリプトが混在: ローマ字 (ASCII Latin)、カタカナ、ひらがな、漢字 (Han)、数字。素朴な実装は ASCII 部分に間違ったフォントを当てて、プロポーショナルな日本語の横に等幅の「API」が並んで見た目が崩壊する。

gpdf の既定挙動は 登録した family で全コードポイントを描く。Noto Sans JP が既定なら、API50 ms も Noto Sans JP の Latin グリフで描かれる — Noto はこれを提供している (日本語スーパーファミリーはだいたい持っている)。結果は単一書体に見える、実際単一書体だから。

family を意図的に混ぜたい場合 (ASCII は condensed サンセリフ、日本語は Noto Sans JP、など) は、両方登録して c.Text 単位で上書き:

c.Text("API の P95 レイテンシは 50 ms 未満です。",
    template.FontFamily("NotoSansJP"))
c.Text("API latency (P95) is under 50 ms.",
    template.FontFamily("InterVariable"))

2 回の c.Text、2 family、スクリプト判定ロジックは自分で書かない。1 行内で両方を混ぜたい (同じ文中で ASCII は Inter、日本語は Noto) なら、それは gpdf v1.2 で対応予定。今の回避策はスクリプト境界で手動分割し、横並びのカラム行でレイアウトする方法。

まだ解けていない部分

Go で日本語 PDF の話は 95% 解けている。残り 5% を正直に書く。

縦書きはまだ未対応。gpdf v1.x は横書きのみ。伝統的な日本語組版 — 右から左へ列、列は上から下、グリフ回転と句読点配置の特殊処理 — は描画の微調整ではなくレイアウトエンジンの深い変更で、設計案のあるオープン issue になっている。着地したら着地する。今どうしても 縦書き が必要なら (書籍や公式往来)、別ツール (Word、InDesign、pandoc + LuaLaTeX パイプライン) で縦書き PDF を作ってから gpdf.Merge で連結するのが現実解。

ルビ (振り仮名) は回避策のみc.Ruby("漢字", "かんじ") のような基本機能はない。児童向けコンテンツや教材で必要なら、上段に小さなかな、下段に通常サイズの漢字を並べた 2 行構造で組む。動きはするが手作業で、カーニングも慎重にやる必要がある。

CJK フォント間のフォールバックは自動ではない。ユーザー入力が JP の漢字と CN 専用字形 ( などは JP/CN で字形が微妙に違う) を混ぜるなら、手動で分割して 2 family を使う必要がある。同じ c.Text 内での自動フォールバックはまだ。実務でここが刺さる文書はかなり少ないが、必要なら JP/CN/KR/EN 混在 PDF (B-070 予定) を参照。

PDF/A-2b 厳格モードで日本語。gpdf は gpdf.WithPDFA で PDF/A を吐けるが、埋込グリフメタデータ・CJK ラン単位の ActualText・タグ付き構造ツリーなど、厳密適合の要件は CJK ケースでまだ詰めている最中。電子帳簿保存法で長期保存する PDF なら、コミット前に veraPDF (無料) などサードパーティで検証する。

どれも一般的な用途 (請求書・レポート・明細書・領収書・証明書) のブロッカーではない。ただ本番で刺さる人がいずれいるから書いておく — 「ロードマップにあります」より「これが回避策です」のほうが誠実なので。

コンプライアンス: 日本市場の文脈

もう一つ、普段あまり書かれない話。2026 年の日本における PDF 生成は、もはや単なるタイポグラフィ問題ではない。2 つの制度がこれをコンプライアンス会話の中に押し込んでいる。

適格請求書 (インボイス制度) は、請求書に所定の項目 (登録番号・適用税率・税額内訳) と改ざん耐性のある保存を要求する。PDF が事実上のデフォルト形式であり、改ざん耐性は PDF デジタル署名 — 厳密には PAdES-B-LT — に写像される。

電子帳簿保存法 (2024 改正) は、電子で受領した請求書の保存義務を拡張した。アーカイブ PDF は所定の完全性要件を満たす必要がある。デファクト目標形式は PDF/A-2b または PDF/A-3b

両方とも PDF ネイティブ機能 に寄っている — 署名、長期検証、PDF/A 埋込メタデータ。ヘッドレスブラウザ経由の HTML→PDF はどちらの要件もきれいには満たさない: Chromium の PDF 出力は PDF/A 適合ではないし、単一ステップでのデジタル署名埋込もできない。ネイティブ Go スタック (gpdf + gpdf/signature による PAdES + gpdf.WithPDFA) はこのチェーン全体をプロセスから出ずに 1 本のパイプラインで通せる。

これは本稿では 予告にとどめる — 署名と PDF/A はそれぞれヒーロー記事 1 本に値する (バックログの B-067 と B-068)。ただ、日本市場で日本語 PDF スタックを今選ぶなら、署名と PDF/A をネイティブで吐けるスタックを選んでおけ。「とりあえず動く」から「監査を通る」への移行税は本物で、後から払うと高い。

FAQ

サーバーやコンテナにフォントをインストールする必要は? ない。gpdf は TTF バイトを読む — システムフォントキャッシュを見ない。os.ReadFile("NotoSansJP-Regular.ttf")//go:embed NotoSansJP-Regular.ttf は macOS / Linux / Windows、distroless コンテナ、AWS Lambda で等価に動く。fontconfigfc-cache -fv も不要。FROM scratch イメージで動くのはこの性質のおかげ。

Noto Sans JP と Source Han Sans JP はどっちがいい? 同じフォント、2 ブランド。Adobe が Source Han Sans JP として公開したものを Google が Noto Sans JP として再配布している。グリフカバレッジは同一。両方 SIL Open Font License — 法務レビュー結果でどちらの経路が通しやすいかで選ぶ。gpdf のサンプルは覚えやすいという理由で Noto Sans JP を既定にしている。

游ゴシック (Yu Gothic) やヒラギノは? OS 同梱の商用フォント。デプロイ先がライセンスを持つ環境なら使える (Windows Server は Yu Gothic 同梱、macOS はヒラギノ同梱) が、TTF ファイル自体の入手と、コンテナビルドでの再配布条件は各自確認が必要。オープンなデプロイには Noto Sans JP または IPAex ゴシック (どちらも自由再配布可) を勧める。

PDF は出るが Ctrl+F で検索が効かない ほぼ確実に ToUnicode CMap 問題。gpdf は既定で書き出すので、gpdf でこれが起きているならビューア名付きで issue を立ててほしい。gofpdf でこれが起きているなら、UTF-8 フラグの有効化 + ビューアが CID フォントをサポートしている確認が必要 (旧バージョンの macOS Preview.app で既知の問題あり)。Adobe Reader か Chrome を対照実験にする。

フォントにない JIS X 0213 の文字を出したい 出ない — 描くグリフがない。実用解は「JIS X 0213 をカバーするフォントを使う」。Noto Sans JP は BMP 全域 + JIS X 0213 第 1 水準をカバーしている。稀な異体字には最終フォールバックとして花園明朝 (Hanazono Mincho) がある。どのフォントにも無いコードポイントは、gpdf は Unicode 置換文字 (U+FFFD) を出す — 無言の豆腐ではなく が出るので、調べるきっかけになる。

CJK は ASCII より遅い? わずかに。gpdf の「complex CJK invoice」ベンチは Apple M1 で 133 µs、ASCII 4×10 テーブルが 108 µs。約 23% の上乗せで、主にグリフ検索とサブセット化のコスト。参考まで、同じ CJK ベンチで go-pdf/fpdf は 254 µs、Maroto v2 は 10.4 ms。日本語描画がサービスのボトルネックになることはまずない。

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、CJK ネイティブ。

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · ドキュメント

次に読む