用 Go 生成日文 PDF 的 2026 权威指南
用 Go 生成日文 PDF 的完整流程。无 CGO、无 Chromium、无豆腐字。涵盖字体、子集化、混合排版、纵排。
TL;DR
如果你的 Go PDF 把 こんにちは 渲染成 5 个豆腐方块,修复方式是两行配置,不是重写。加载一个日文 TTF,把 gpdf.WithFont 传给 NewDocument,写日文。gpdf 会自动子集化字形表,所以输出只带你实际用过的字符 — 约 30 KB,而不是整套 5 MB 的字体。本文是这条路径的地图:为什么 Go 里的日文 PDF 出奇地难、2026 年真正的四个选项、一份完整可跑的示例、字体子集化的内部机制、混合排版的边界情况,以及仍然难解的部分。
为什么需要这篇指南
Go 里渲染一个日文 PDF 本该是 5 分钟的事。对很多团队而言,它要花一天半。
典型剧情:有人换上 AddUTF8Font,PDF 里出现一排空白方框 — 臭名昭著的"豆腐" — 一个资深工程师花一下午排查到底是字体路径、子集标志、CMap、UTF-8 开关,还是 PDF 阅读器。到傍晚 Slack 上出现了一条名为"为什么漢字还在坏"的讨论,第二天提交了一个新增三个谁都后悔的辅助函数的 PR。
根源不是这些任何一条。Go 上寿命最长的 PDF 库是 2002 年为 PHP 和 Latin-1 设计的,之后几乎所有日文教程都在与这个遗产作战。本文是 2026 版:从干净的起点出发,真正能工作的做法,以及仍然困难的部分。
本文代码基于 gpdf v1.x (2026-04)。基准数据来自 Apple M1 + Go 1.25。
90 秒看懂豆腐问题
PDF 不在乎 Unicode。它在乎的是字形 ID — 嵌入字体字形表的整数索引。要把 "こんにちは" 写进 PDF,必须有人完成:
- 解析 TTF,从
cmap子表里找出每个码点对应的字形 ID。 - 写 ToUnicode CMap,这样 PDF 阅读器在用户复制或搜索时能把字形映射回文本。
- 子集化,不要把 Noto Sans JP 的两万字形全塞进去。
- 嵌入,正确拼接
name/OS/2/head表和编码对象。
任何一步缺失或出错,阅读器都找不到字形,画出豆腐。已归档的 jung-kurt/gofpdf 和 go-pdf/fpdf 系列把上面所有步骤后挂在单字节字体模型上 — 2002 年的 FPDF 只认 Latin-1。这就是为什么设置易碎、输出经常嵌入整套字体而非子集、故障模式因操作系统和阅读器而异。
gpdf 把 CJK 当成一等用例。TTF 子集器在核心包内。ToUnicode CMap 自动写出。没有单字节字体的历史包袱,因此也没有 AddUTF8Font 的折腾。
2026 年的四个真实选项
先摆牌。"支持日文"指"给定正确 TTF 时,能不崩溃、不豆腐地渲染任意日文"。
| 选项 | 许可 | 依赖 | CJK 路径 | 300 字文档大小 | 备注 |
|---|---|---|---|---|---|
go-pdf/fpdf (2025 归档) | MIT | 标准库 | AddUTF8Font 后挂 | 约 5 MB (整套) | 后挂在 Latin-1 核心上。子集化需显式开启且不完整。 |
signintech/gopdf | MIT | 标准库 | AddTTFFont + 手工 | 约 3 MB | 低层。自己写坐标。有子集化但要自己驱动。 |
chromedp + Chromium | MIT + Chrome | Chromium 二进制 | 浏览器原生 | 可变 | HTML/CSS。容器里要装字体。镜像 500 MB+。 |
gpdf | MIT | 仅标准库 | 原生,自动子集化 | 约 30 KB | 纯 Go。Builder API。自动写 ToUnicode CMap。 |
两点值得强调。
"整套嵌入"与"自动子集"之间 160 倍的差不是小事。 一张十条明细的日文电商发票,唯一的日文字符可能只有 120 个。每张发票都嵌入整套 Noto Sans JP (5.1 MB) 意味着到年底,同样 5 MB 的字形数据会在对象存储里存在一千万份。子集嵌入只带你用到的字形。
"chromedp 能用"是事实,也是最贵的答案。 如果团队已经在跑一队无头 Chrome 做截图,给它加个 PDF 用途也行。如果没跑,仅仅为了打印日文就立起一个 Chromium,相对于一个 40 行 Go 能解决的问题而言,基础设施开销过大。
最短可行路径
先试这个。完整可运行 — 拷贝保存为 main.go,把两个 TTF 放在旁边,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 和 Bold 是不同的 TTF 文件,
name表不同)。Gothic 和 Mincho、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) 生成 (第三个参数是 UTF-8 标志) 是 4.9 MB。同样的输入,同样的输出文本,文件大 143 倍。原因是 fpdf 代码路径在输出时不做子集化,直接嵌入整张字体表。
生产影响:
- 每秒 10 张发票 (常见 SaaS 规模),子集差 = 0.3 MB/s vs 43 MB/s 的出口字节差。负载均衡器对此有意见。
- 冷存储费用与 PDF 大小线性。500 万张归档发票 × 5 MB = 25 TB。× 30 KB = 150 GB。对象存储价格把这做成月度四位数对比两位数的差别。
- 邮件投递 附件限额 10〜25 MB。一张 5 MB 日文发票 + 其他附件 + MIME 编码,很容易顶到上限。
gpdf 在渲染时子集化。没有开启开关。要看哪些字形进了输出,可以用 gpdf 的本地验证工具,但短版本是:如果你用了 株、式、会、社,这四个字形进输出,其余 17,496 不进。
混合排版:同一行里的漢字 + 仮名 + ASCII
日文文本很少只有日文。真实世界的一行看起来像这样:
API の P95 レイテンシは 50 ms 未満です。
5 种文字并存:罗马字 (ASCII Latin)、片假名、平假名、漢字 (Han)、数字。朴素实现给 ASCII 部分挑错字体,结果单间距的 "API" 贴着比例排布的日文,视觉崩坏。
gpdf 的默认行为是用注册的 family 渲染每个码点。如果 Noto Sans JP 是默认,API 和 50 ms 就用 Noto Sans JP 的拉丁字形来画 — 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"))
两次 c.Text,两个 family,你的代码里没有脚本检测逻辑。同一行内混合 (同一句子里 ASCII 走 Inter、日文走 Noto) 的功能计划在 gpdf v1.2 加入;现在的绕行是手动按脚本边界切分,用横向的列排布。
仍然难解的部分
Go 上的日文 PDF 故事已解决 95%。诚实写出剩下 5%。
纵排 (縦書き) 尚未支持。gpdf v1.x 仅支持横排。传统日文排版 — 从右向左的列、自上而下的字符、合适的字形旋转和标点重定位 — 是布局引擎的深度改动,不是渲染微调。有已开设计案的 issue,落地时会落地。现在若必须纵排 (书籍、正式书信),用其它工具 (Word、InDesign、pandoc + LuaLaTeX 管道) 产出纵排 PDF,再用 gpdf.Merge 合并。
旁注假名 (ルビ、振り仮名) 只能绕行。没有 c.Ruby("漢字", "かんじ") 原语。若儿童内容或语言教材需要,绕行是两行列结构:上面小号假名、下面普通汉字,对齐。能用,手动,假名边界的精细字距要小心。
多 CJK 字体间的自动回退不存在。若用户输入混了日文漢字和中文专有字 (直、骨、角 在 JP/CN 字形略有差别),你要手动分割并用两个 family。同一 c.Text 调用内不会跨 family 自动回退。实际上很少有文档需要这种,但若需要请参考 JP/CN/KR/EN 混排 PDF (B-070 待发)。
严格 PDF/A-2b + 日文。gpdf 通过 gpdf.WithPDFA 产出 PDF/A,但嵌入字形元数据、CJK 段的 ActualText、带标签的结构树等严格合规要求在 CJK 场景下仍在打磨。若要长期归档 (中国的会计凭证归档条例、日本的电子帐簿保存法),在提交前用第三方工具 (veraPDF 免费) 校验。
这些都不是常见场景 (发票、报表、对账单、收据、凭证) 的阻塞项。写出来是因为总会有人在生产里踩到其中之一,"在路线图上"没有"这里是绕行方案"实用。
合规视角
一条生态上下文通常说得太少:2026 年的日文 PDF 生成不只是排版问题。两项监管要求把它推进了合规讨论。
适格请求書 (资格发票) 制度要求发票包含特定字段 (登录业务编号、适用税率、税额明细) 并以防篡改方式保存。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) 可以在单一流水线、不出进程地完成全链条。
这是预告,不是深潜 — 签名与 PDF/A 各自值一篇长文 (待发 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 上等价。无需 fontconfig,无需 fc-cache -fv。这是 gpdf 能在 FROM scratch 镜像里运行的理由之一。
Noto Sans JP vs Source Han Sans JP 有区别吗? 同字体,两个名字。Adobe 发行 Source Han Sans JP,Google 重新打包为 Noto Sans JP。字形覆盖相同。都是 SIL Open Font License — 选法务容易通过的那个。gpdf 示例默认用 Noto Sans JP 是因为文件名好记。
游ゴシック (Yu Gothic) 或 Hiragino 呢? OS 自带的商用字体。只要部署目标授权 (Windows Server 带 Yu Gothic、macOS 带 Hiragino) 就能用,但 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 第一水平。罕见异体有 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
延伸阅读
- 如何用 gpdf 嵌入日文字体? — 不含背景的三行配方
- 如何用 Noto Sans JP 配合 gpdf? — Regular / Bold / Medium 字重设置
- gpdf 的 12 列网格如何工作? — 取代光标计算的布局习语
- go-pdf/fpdf 也归档了。2026 年的 Go PDF 栈 — 更广的 2026 版图