为什么 gpdf 生成的 PDF 中日文显示为方块(豆腐字)?
日文字符变成空方块(□),通常是字体未注册或族名不匹配。整理 4 种常见原因和最快的修复路径。
这个问题的另一种表达
我用 gpdf 写了日文,输出的 PDF 里那些字都变成了空方块。这是什么,怎么让真的日文字形出现在文件里?
速答
这就是豆腐字(tofu)——PDF 查看器在嵌入的字体里找不到对应 Unicode 码位的字形时,会画一个占位矩形。原因有 4 种,其中一种远比其余的常见。
按频率排序:
- 没有注册 CJK 字体。
gpdf.NewDocument里没有WithFont调用,所以文档回落到 PDF Base-14 字体(Helvetica、Times、Courier)。它们都不覆盖 U+3040–U+9FFF。 - 注册了 CJK 字体,但
c.Text的族名不对。WithFont("NotoSansJP", ...)设好了,但文本上写着template.FontFamily("Arial"),于是 gpdf 在一个 Latin 字体里查日文字形。 - 字体文件本身没有 CJK 字形。 磁盘上的 TTF 是 Latin 子集(
NotoSans-Regular.ttf,不是NotoSansJP-Regular.ttf)。文件名看起来对,覆盖却是空的。 - 字节在到达 gpdf 之前就坏了。 字符串在上游被当成 Shift-JIS 或 Latin-1 解码过,你想渲染的已经不再是日文码位。如果看到的是
縺ゅ→縺而不是方块,属于这一种。
原因 #1 的标准修复
十有八九是这个:
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("こんにちは、世界。")
})
})
data, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("hello.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
两行完成字体注册与默认设置。不依赖 CGO,不需要 AddUTF8Font 的那套簿记。如果之前看到 □□□□□、□□。,现在把上面这段代码和真实的 NotoSansJP-Regular.ttf 放一起跑,会出真的字形。
从 Google Fonts 下载 NotoSansJP-Regular.ttf。
怎么判断是哪种原因
要盯三个地方:文档构造处、写文本的地方、以及 TTF 文件本身。
输出是整齐的 □□□(所有矩形一模一样):原因 1、2 或 3。PDF 里确实嵌入了一个字体,但它没有那些字形。在 Acrobat 中打开 PDF,进入 文件 → 属性 → 字体,看实际嵌入的是什么。如果只有 Helvetica / Times / Courier,就是原因 1;如果 NotoSansJP 已经列出来但还是方块,就是原因 2 或 3。
输出像 縺ゅ→縺 或 ã"ã‚"ã«ã¡ã¯ 这种乱码:原因 4。日文字符串在到达 gpdf 之前已经被重新编码过了。常见元凶:Excel 保存的 Shift-JIS CSV 被 os.ReadFile 直接当成 UTF-8 读入,或者一个 HTTP 接口没有声明 charset=utf-8。修的是解码器,不是 PDF。
部分字形正常,部分是方块:字体覆盖不完整。号称"日文支持"的字体可能只有假名和常用汉字,而跳过 鬱、龠 这类罕用字。切换到 Noto Sans JP(覆盖 JIS X 0213)或 Source Han Sans JP 即可。
原因 2 细节:字体对了,族名错了
这种最隐蔽,因为字体的确被嵌入了,只是没被用。最小复现:
doc := gpdf.NewDocument(
gpdf.WithFont("NotoSansJP", font),
// 漏了 WithDefaultFont
)
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("こんにちは") // 用的是默认字体 Helvetica
})
})
修复:要么在 NewDocument 里加 gpdf.WithDefaultFont("NotoSansJP", 12),要么在每个要渲染日文的 c.Text 上都传 template.FontFamily("NotoSansJP")。WithFont 里的族名和 c.Text 里的族名必须完全一致,包括大小写。NotoSansJP 和 notosansjp 在 gpdf 看来是两个不同的字体。
原因 3 细节:拿错了 TTF 文件
NotoSans-Regular.ttf 和 NotoSansJP-Regular.ttf 是两个不同的文件。前者是 Latin 字体,CJK 覆盖为零;后者是日文版,约 17,000 个字形。目录列表里看起来几乎一样,自动补全很容易补成错的那个。
gpdf 在注册字体时不做字形覆盖校验——你给它字节,它就相信你。失败只会在渲染时以豆腐字的形式暴露。
最快的检查方法:
- macOS:
字体册里双击文件,能看到字形网格 - Linux:
otfinfo -u NotoSans-Regular.ttf打印 Unicode 覆盖 - 跨平台:fontTools 的
ttx -t cmap NotoSans-Regular.ttf把 cmap 表导出为 XML
如果列表里没有 U+3042(あ),手上拿的就是 Latin 子集。
原因 4 细节:编码损坏
这其实和 gpdf 无关。传给 c.Text 的字符串在更早的时候就已经坏了。渲染前先打印:
text := loadLabelFromSomewhere()
fmt.Printf("%q\n", text) // 打印实际 rune
c.Text(text)
如果这里输出 "縺ゅ→縺" 而不是 "あいうえ",损坏发生在上游。gpdf 修不了——去找 UTF-8 被错误解码的那一步。
常见上游元凶:
- 用
os.ReadFile读 Excel 导出的 Shift-JIS CSV,然后直接string(data)转换 - 数据库字段定义为
latin1或utf8mb3(而不是utf8mb4),存的本来就是乱码 - HTTP 响应没有
Content-Type: application/json; charset=utf-8,客户端猜成了 Latin-1
一个容易忽略的边界情况
gpdf 会自动做字形子集化,时机是 Generate() 被调用的那一刻。如果你在文档构造中先渲染 こんにちは,后渲染 鬱陶しい,第二次也会被正确加入子集。但如果生成完 PDF 再用 Acrobat 打开、手动输入一个原本没有出现过的汉字,那个字就会变成豆腐——子集已经冻结了。不要后编辑 PDF,回到 Go 里重新 Generate()。
延伸阅读
- 如何在 gpdf 中嵌入日文字体? ——
WithFont的完整走查,含粗体/斜体变体与多 CJK 文档 - 如何在 gpdf 中使用 Noto Sans JP? —— 该选哪个 Noto 文件,以及用
go:embed简化分发 - Go 日文 PDF 决定版指南(2026) —— 字体、竖排、ruby 及日文特有排版的长篇指南
试试 gpdf
gpdf 是一个 Go PDF 生成库。MIT 协议、零外部依赖、原生支持 CJK。
go get github.com/gpdf-dev/gpdf