全部文章

为什么 gpdf 生成的 PDF 中日文显示为方块(豆腐字)?

日文字符变成空方块(□),通常是字体未注册或族名不匹配。整理 4 种常见原因和最快的修复路径。

作者: gpdf team

这个问题的另一种表达

我用 gpdf 写了日文,输出的 PDF 里那些字都变成了空方块。这是什么,怎么让真的日文字形出现在文件里?

速答

这就是豆腐字(tofu)——PDF 查看器在嵌入的字体里找不到对应 Unicode 码位的字形时,会画一个占位矩形。原因有 4 种,其中一种远比其余的常见。

按频率排序:

  1. 没有注册 CJK 字体。 gpdf.NewDocument 里没有 WithFont 调用,所以文档回落到 PDF Base-14 字体(Helvetica、Times、Courier)。它们都不覆盖 U+3040–U+9FFF。
  2. 注册了 CJK 字体,但 c.Text 的族名不对。 WithFont("NotoSansJP", ...) 设好了,但文本上写着 template.FontFamily("Arial"),于是 gpdf 在一个 Latin 字体里查日文字形。
  3. 字体文件本身没有 CJK 字形。 磁盘上的 TTF 是 Latin 子集(NotoSans-Regular.ttf,不是 NotoSansJP-Regular.ttf)。文件名看起来对,覆盖却是空的。
  4. 字节在到达 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 里的族名必须完全一致,包括大小写。NotoSansJPnotosansjp 在 gpdf 看来是两个不同的字体。

原因 3 细节:拿错了 TTF 文件

NotoSans-Regular.ttfNotoSansJP-Regular.ttf 是两个不同的文件。前者是 Latin 字体,CJK 覆盖为零;后者是日文版,约 17,000 个字形。目录列表里看起来几乎一样,自动补全很容易补成错的那个。

gpdf 在注册字体时不做字形覆盖校验——你给它字节,它就相信你。失败只会在渲染时以豆腐字的形式暴露。

最快的检查方法:

  • macOS:字体册 里双击文件,能看到字形网格
  • Linux:otfinfo -u NotoSans-Regular.ttf 打印 Unicode 覆盖
  • 跨平台:fontToolsttx -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) 转换
  • 数据库字段定义为 latin1utf8mb3(而不是 utf8mb4),存的本来就是乱码
  • HTTP 响应没有 Content-Type: application/json; charset=utf-8,客户端猜成了 Latin-1

一个容易忽略的边界情况

gpdf 会自动做字形子集化,时机是 Generate() 被调用的那一刻。如果你在文档构造中先渲染 こんにちは,后渲染 鬱陶しい,第二次也会被正确加入子集。但如果生成完 PDF 再用 Acrobat 打开、手动输入一个原本没有出现过的汉字,那个字就会变成豆腐——子集已经冻结了。不要后编辑 PDF,回到 Go 里重新 Generate()

延伸阅读

试试 gpdf

gpdf 是一个 Go PDF 生成库。MIT 协议、零外部依赖、原生支持 CJK。

go get github.com/gpdf-dev/gpdf

⭐ 在 GitHub 上 Star · 阅读文档