全部文章

如何在 gpdf 中使用 Noto Sans JP?

用 gpdf.WithFont 注册 static 版 NotoSansJP-Regular.ttf,不要用 variable font。gpdf 会把 17,000 个字形子集化到每份 PDF 不到 40 KB。

作者: gpdf team

把问题换个说法

你想在 gpdf 文档里渲染日语,字体选了 Noto Sans JP — Google 发布的 SIL OFL 免费无衬线字体,完整覆盖 JIS 字符集。你已经下好了 Google Fonts 的 zip 包。从这里开始,你想知道的三件事是:选哪个文件、注册哪几个字重、zip 里藏着的那一个坑是什么

结论 (TL;DR)

解压 zip 之后,用 static/NotoSansJP-Regular.ttf — 不是根目录下的 variable font。把它传给 gpdf.WithFont("NotoSansJP", bytes) 并设为默认字体。gpdf 会从大约 17,000 个字形里只把你实际渲染过的那些子集化后嵌入 PDF — 一份普通发票最终携带的字体数据通常在 20–40 KB。

完整示例

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", 11),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("請求書", template.FontSize(28), template.Bold())
            c.Text("Noto Sans JP、これで十分。")
        })
    })

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

Google Fonts 下载 zip,解压后把 static/NotoSansJP-Regular.ttf 放到 main.go 旁边,运行 go run main.go — 就能得到一页的 PDF。

选 static TTF,不要选 variable font

在 Google Fonts 点 Get font → Download all,解压 zip,里面有两组看起来差不多、实际上完全不一样的文件:

  • 根目录的 NotoSansJP-VariableFont_wght.ttfvariable 字体,把 100–900 所有字重合并在一个文件里,约 7 MB
  • static/ 目录 — 9 个独立的 TTF,从 NotoSansJP-Thin.ttfNotoSansJP-Black.ttf,每个约 5 MB

static/ 里的那一个

gpdf 的 TrueType 解析器是刻意收窄过功能的。它处理字形轮廓、复合字形、cmaphmtx — 渲染固定字重文本所需的表。但它不处理让 variable font 真正可变的 fvar / gvar / HVAR 表。如果你把 VariableFont_wght.ttf 喂给它,要么解析器直接报错,要么更糟糕 — 它拿到默认实例的字形,然后默默无视你以为自己设好的字重轴。

文件大小也支持 static 方案。variable 字体把整个字重轴上的所有 outline 都塞在一个文件里,这是设计初衷 — 但如果你只用 Regular,就等于白付了另外 8 个字重的数据。static Regular 是 5 MB,variable 是 7 MB。子集化会把两者都削掉,但 static 是更干净的输入。

关键就是这 4 行

真正有意思的只有构造函数的选项:

doc := gpdf.NewDocument(
    gpdf.WithFont("NotoSansJP", font),
    gpdf.WithDefaultFont("NotoSansJP", 11),
)

字体族名 ("NotoSansJP") 是任意的。gpdf 把它当查找键用 — 不是文件路径,也不是读自字体元数据的名字。如果你的项目里 "body""jp""Noto" 读起来更顺手就用那个。只要后面 template.FontFamily(...) 保持一致即可。

WithDefaultFont 能让你不用在每个 c.Text 里写 template.FontFamily("NotoSansJP")。省掉它,gpdf 会回落到 Helvetica — 而 Helvetica 不覆盖任何 CJK 码位,你会得到一篇只有标题正常、正文全是豆腐 (□□□) 的 PDF。

到底需要哪几个字重?

发票、收据、业务报告只需要 Regular 和 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", 11),
)

-Bold 这个后缀注册之后,template.Bold() 会自动挑到它。-Italic-BoldItalic 规则相同。不过 Noto Sans JP 没有斜体 — CJK 字体通常不发布斜体,因为字形本身没有自然的倾斜形式。需要在日语里做强调时,用颜色、字号或粗体代替。

宣传册需要 Medium 或 SemiBold 时,用任意后缀注册,再按字体族名直接引用即可:

gpdf.WithFont("NotoSansJP-Medium", medium)
// ...
c.Text("見出し", template.FontFamily("NotoSansJP-Medium"))

基于后缀的 Bold/Italic 快捷方式只在 -Bold / -Italic / -BoldItalic 这三个字面名上生效,其他都按族名显式引用。

子集化之后的真实体积

Noto Sans JP Regular 磁盘上约 5 MB。这个数字让一些团队去另建字体 CDN、或者给 PDF 做后处理剥离字体。用 gpdf 不需要。

下面是实际落到 PDF 里的数据量:

文档使用字形数PDF 中的字体数据
一行收据 (约 15 字)约 14约 11 KB
普通发票 (约 200 字)约 80约 28 KB
10 页报告 (约 8,000 字)约 900约 180 KB
字典级满字 (JIS Level 1 全)约 6,800约 2.1 MB

(gpdf v1.0,静态子集化开启。数字会因字形 ID 落在 CFF 和 hmtx 哪一块而上下浮动几 KB)

一份最终 50 KB 的发票 PDF,一半以上是字体数据。但比起不做子集化直接嵌入 5 MB 来说,这点开销几乎可以忽略,查看器会瞬间打开。

Noto Sans JP 与 Noto Sans CJK JP — 不要搞混

Noto 家族里有两个都声称能处理日语的子族,名字相似得让人以为可以互换。实际上不是。

Noto Sans JP 是你要用的。TTF 格式,单一语言,每个字重一个文件。就是 Google Fonts 上下载的那个。

Noto Sans CJK JP 是覆盖整个 CJK 的超级族。以 OpenType Collection (.ttc) 形式发布,把日语、简体中文、繁体中文、韩语的字形经过汉字统合 (Han unification) 后塞在一个文件里。早期 Noto 发行版和 notofonts.github.io/noto-cjk 上的都是这个。

gpdf 直接支持 TTF。TTC 是容器格式 — 你需要在传给 WithFont 之前挑选正确的 face index,而且每个 face 里的 cmap 是针对特定 CJK 地区调过的,等于你在默默地替汉字统合做选择。直接选 JP 专用的 TTF 会让这些选择显式化。

新项目用 Noto Sans JP。如果遗留项目里已经有 NotoSansCJK-Regular.ttc,用 pyftsubsetfonttools 抽出 JP face,把结果 TTF 作为项目里的标准产物 check in。

把字体编译进二进制

PDF 生成服务大多跑在容器里,发字体最干净的方式是编译进去:

package main

import (
    _ "embed"

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

//go:embed NotoSansJP-Regular.ttf
var notoJP []byte

func main() {
    doc := gpdf.NewDocument(
        gpdf.WithFont("NotoSansJP", notoJP),
        gpdf.WithDefaultFont("NotoSansJP", 11),
    )
    // ...
}

二进制从约 8 MB 涨到约 13 MB。换来的是:Docker 镜像只有一个产物而不是两个,COPY --from=builder /app /app 就够用,也不会有人因为忘记复制字体文件而上线一个坏掉的容器。每天生成几千份 PDF 的批处理任务,这是合理的默认方案。

相关阅读

试用 gpdf

gpdf 是 Go 的 PDF 生成库。MIT 许可,零外部依赖,原生 CJK 支持。

go get github.com/gpdf-dev/gpdf

⭐ 在 GitHub 上加星 · 阅读文档