gofpdf 已归档:迁移到 gpdf 的完整指南
gofpdf 于 2021 年归档,后继 go-pdf/fpdf 也在 2025 年停止。纯 Go、零依赖、原生 CJK 的 gpdf 迁移指南。
TL;DR
gpdf 是一个纯 Go、零外部依赖的 PDF 生成库,原生支持 CJK(无需 AddUTF8Font 那套繁琐流程),用 12 栅格替代 SetXY 的像素级定位,在相同负载下 比 gofpdf 快约 10 倍。迁移的核心是把命令式的游标调用换成声明式的构建器。本文用 5 组 Before/After 示例讲完整个映射。
上周一位同事开了个新 Go 项目,跑了 go get github.com/jung-kurt/gofpdf,10 分钟后发来 GitHub 的横幅截图:"This repository has been archived by the owner. It is now read-only." 接着一句:"等等,那个 fork 也归档了?"
是的,两个都归档了。
jung-kurt/gofpdf 于 2021 年 9 月 8 日 归档。社区 fork go-pdf/fpdf 最后一次发版是 2023 年,2025 年正式归档。Stack Overflow 和中文博客上约三分之二的 Go PDF 答案仍然指向 gofpdf,但它已经只读 4 年多,连接棒者也消失了。
如果你的 gofpdf 代码已经在生产,这篇是迁移地图。如果你正新开项目因为搜索结果推 gofpdf 而反手就装,这篇就是替代方案。
为什么 gofpdf 真的复活不了
开源库不一定会死。有时原维护者退出,有人接手。大家都以为 gofpdf 会这样 —— 一段时间内也的确如此。go-pdf/fpdf 重新整理了代码、修了几个老 bug、接受 PR,看起来像真正的延续。
但 2025 年初,fork 也归档了。README 写着:"该项目不再积极维护,请考虑使用其他库。"
原因不重要,结果才重要:所有依赖 gofpdf 的 Go 项目,现在都坐在两层无人维护的代码之上。安全漏洞不会被修复。PDF 2.0 规范 2020 年发布,gofpdf 大部分都没跟进。Go 1.25 的循环变量语义现在还能和 gofpdf 配合,但明天坏了的话只能自己维护一个 fork。
这不是"这个库有 bug"的问题,是供应链的问题。
对中国团队尤其重要 —— 电子发票、增值税凭证、合规归档,拿一个未维护的库当技术栈基础,难以通过审计。
中文团队用 gofpdf 都在做什么
翻 GitHub Issues 和中文社区的提问,gofpdf 的主要用途集中在:
- 发票、收据、送货单 —— 页眉、客户信息、明细表、合计、页脚
- 报表 —— 重复页眉页脚、页码、图表(以图像形式插入)的多页文档
- 证书和表单 —— 固定位置文字叠加在模板之上
- CJK 文档 —— 中日韩文发票和物流标签
前三类 gpdf 的 builder API 都能直接覆盖。第四类 CJK 才是 gpdf 相对 gofpdf 最大的差距 —— gofpdf 要求调用 AddUTF8Font,管理 TTF 文件路径,并祈祷你的文本不会超出基本字符面。gpdf 从设计之初就把 CJK 当作一等公民:注册 TrueType 字体,写中文,输出 PDF。
API 对照表
下表是速查表。后面章节逐个讲 5 组具体的 Before/After。
| 你想做什么 | gofpdf | gpdf |
|---|---|---|
| 创建文档 | gofpdf.New("P", "mm", "A4", "") | gpdf.NewDocument(gpdf.WithPageSize(document.A4)) |
| 添加页面 | pdf.AddPage() | doc.AddPage() (返回 *PageBuilder) |
| 设置字体 | pdf.SetFont("Arial", "B", 16) | template.FontFamily(...) / template.Bold() / template.FontSize(16) |
| 注册 TTF (CJK) | pdf.AddUTF8Font("noto", "", "NotoSansSC-Regular.ttf") | gpdf.WithFont("NotoSansSC", ttfBytes) (构造时传入) |
| 写单行文本 | pdf.Cell(40, 10, "hi") | c.Text("hi") |
| 写自动换行文本 | pdf.MultiCell(0, 10, body, "", "L", false) | c.Text(body) (自动换行) |
| 设置文字颜色 | pdf.SetTextColor(255, 0, 0) | template.TextColor(pdf.Red) (per-text 选项) |
| 画横线 | pdf.Line(x1, y1, x2, y2) | c.Line(template.LineThickness(document.Pt(1))) |
| 嵌入图像 | pdf.ImageOptions("logo.png", x, y, w, h, ...) | c.Image(imgBytes, template.FitWidth(document.Mm(50))) |
| 设置光标坐标 | pdf.SetXY(x, y) | (无对应 —— 用行/列,或 page.Absolute(x, y, fn)) |
| 每页重复页眉 | pdf.SetHeaderFunc(fn) | doc.Header(fn) |
| 每页重复页脚 | pdf.SetFooterFunc(fn) | doc.Footer(fn) |
| 页码 | pdf.PageNo()(手动) | c.PageNumber() / c.TotalPages() |
| 输出到文件 | pdf.OutputFileAndClose("out.pdf") | data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644) |
| 输出到 Writer | pdf.Output(w) | doc.Render(w) |
最大的变化是 API 形态:gofpdf 是命令式,gpdf 是声明式。gofpdf 里你推着光标走,光标到哪写到哪。gpdf 里你描述一棵由行与列组成的树,布局引擎负责摆放。前几段代码 gpdf 写起来感觉更长。写到第三个,你就不再想念 SetXY 了。
关于单位。gofpdf 构造时选一种基准单位("mm" / "pt" / "in"),之后都按那个来。gpdf 内部统一用 pt,调用时用 document.Mm(20)、document.Pt(12)、document.Cm(1) 等帮助函数,按场景选单位。这更像 CSS 的思路,一旦用 document.Mm(15) 定好页边距,就不再需要操心单位的事。
Before / After 1:最简单的 PDF
"hello world" 对。gofpdf 的简洁是它流行的原因之一。gpdf 版本多几行,因为它在构建一棵树,不是驱动光标。
Before — gofpdf:
package main
import "github.com/jung-kurt/gofpdf"
func main() {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetFont("Arial", "B", 24)
pdf.Cell(40, 10, "Hello, World!")
pdf.OutputFileAndClose("hello.pdf")
}
After — gpdf:
package main
import (
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("Hello, World!", template.FontSize(24), template.Bold())
})
})
data, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("hello.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
栅格替你干活。AutoRow 添加一个高度由内容决定的行,r.Col(12, ...) 表示"这列占满 12 栅格"。就是 Bootstrap 思路搬到 PDF 页面上。
Generate() 返回字节切片;Render(w) 流式写入 io.Writer,避免分配。没有"关闭文件"一步,因为 gpdf 不持有文件句柄。
Before / After 2:发票明细表
表格是 gofpdf 最啰嗦的地方。它没有内建表格,你得在嵌套循环里调 Cell,自己算列宽,用 Ln(-1) 换行。网上一半的 gofpdf 发票教程,代码量都耗在了表格样板上。
Before — gofpdf:
pdf.SetFont("Arial", "B", 11)
pdf.SetFillColor(220, 220, 220)
pdf.CellFormat(80, 8, "品名", "1", 0, "L", true, 0, "")
pdf.CellFormat(20, 8, "数量", "1", 0, "C", true, 0, "")
pdf.CellFormat(30, 8, "单价", "1", 0, "R", true, 0, "")
pdf.CellFormat(30, 8, "金额", "1", 1, "R", true, 0, "")
pdf.SetFont("Arial", "", 11)
items := [][]string{
{"前端开发", "40h", "¥1,500", "¥60,000"},
{"后端开发", "60h", "¥1,500", "¥90,000"},
{"UI 设计", "20h", "¥1,200", "¥24,000"},
}
for _, row := range items {
pdf.CellFormat(80, 8, row[0], "1", 0, "L", false, 0, "")
pdf.CellFormat(20, 8, row[1], "1", 0, "C", false, 0, "")
pdf.CellFormat(30, 8, row[2], "1", 0, "R", false, 0, "")
pdf.CellFormat(30, 8, row[3], "1", 1, "R", false, 0, "")
}
列宽自己心算,品名换行则崩。
After — gpdf:
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table(
[]string{"品名", "数量", "单价", "金额"},
[][]string{
{"前端开发", "40h", "¥1,500", "¥60,000"},
{"后端开发", "60h", "¥1,500", "¥90,000"},
{"UI 设计", "20h", "¥1,200", "¥24,000"},
},
template.ColumnWidths(50, 15, 15, 20),
template.TableHeaderStyle(
template.Bold(),
template.TextColor(pdf.White),
template.BgColor(pdf.RGBHex(0x1A237E)),
),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)
})
})
ColumnWidths(50, 15, 15, 20) 里的数字是所在列宽的百分比,不是绝对毫米。把这张表放进 r.Col(6, ...),同一组百分比依然成立 —— 这是 CellFormat 必须包一层才能达到的抽象。
自动换行。自动分页 —— 表格超出下边距时,下一页自动重绘表头。
Before / After 3:不用再跳那套 CJK 舞步
这是我决定弃用 gofpdf 的那一点。在 gofpdf 里要渲染中文,你得调 AddUTF8Font,指一个磁盘 TTF 路径,设置字体,然后祈祷。子集化大部分时候能用。有些 TTF 会触发 glyph-id 冲突并吐出乱码。错误信息帮不上忙。
Before — gofpdf:
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddUTF8Font("notosanssc", "", "NotoSansSC-Regular.ttf")
pdf.AddPage()
pdf.SetFont("notosanssc", "", 14)
pdf.Cell(0, 10, "你好,世界。")
pdf.OutputFileAndClose("zh.pdf")
两颗地雷:TTF 必须在运行时以指定路径存在于运行二进制的机器上(所以你的 Docker 镜像得捆一份字体);Cell 宽度给 0 意思是"到右边距",但中文里宽度估算常算不准全角字符,容易超框被截断。
After — gpdf:
package main
import (
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
fontData, err := os.ReadFile("NotoSansSC-Regular.ttf")
if err != nil {
log.Fatal(err)
}
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
gpdf.WithFont("NotoSansSC", fontData),
gpdf.WithDefaultFont("NotoSansSC", 14),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("你好,世界。")
c.Text("天行健,君子以自强不息。")
c.Text("北京市朝阳区建国门外大街 1 号")
})
})
data, _ := doc.Generate()
os.WriteFile("zh.pdf", data, 0o644)
}
两处不同。
其一:传字节切片,不传路径。用 //go:embed NotoSansSC-Regular.ttf 把 TTF 嵌进二进制,部署时就自包含,不会出现"生产环境找不到字体"的情况。
其二:gpdf 的 TrueType 子集化器理解 CJK 的 cmap 格式(4、6、12)与 Identity-H 编码。最终 PDF 只包含你实际用到的字形 —— 一份 200 字符的中文发票嵌入 NotoSansSC 后,字体子集约 30 KB,而不是完整 4 MB。如果你见过 gofpdf 生成一页中文就 5 MB 的 PDF,这是你会第一眼察觉的差别。
关于思源黑体、方正系列、字体回退等更深入的 CJK 话题,会在后续文章细讲。
Before / After 4:每页固定页眉 + 页脚带页码
gofpdf 处理重复框架用 SetHeaderFunc / SetFooterFunc,两个都接收一个针对当前光标运行的 func()。页码用 pdf.PageNo() + pdf.AliasNbPages()。
Before — gofpdf:
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetHeaderFunc(func() {
pdf.SetFont("Arial", "B", 12)
pdf.Cell(0, 10, "ACME 科技有限公司")
pdf.Ln(15)
})
pdf.SetFooterFunc(func() {
pdf.SetY(-15)
pdf.SetFont("Arial", "I", 8)
pdf.CellFormat(0, 10,
fmt.Sprintf("Page %d/{nb}", pdf.PageNo()),
"", 0, "C", false, 0, "")
})
pdf.AliasNbPages("")
pdf.AddPage()
// ... body ...
{nb} 是 gofpdf 在输出时用总页数重写的哨兵。能用,但得"知道"才行。
After — gpdf:
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
doc.Header(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("ACME 科技有限公司", template.Bold(), template.FontSize(12))
c.Line(template.LineColor(pdf.Gray(0.7)))
c.Spacer(document.Mm(4))
})
})
})
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("ACME 科技有限公司",
template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
})
r.Col(6, func(c *template.ColBuilder) {
// "第 X 页 / 共 Y 页" —— 两者都是占位符,
// 分页完成后由布局引擎展开。
c.PageNumber(template.AlignRight(),
template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
})
})
})
for i := 0; i < 10; i++ {
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text(fmt.Sprintf("第 %d 页正文。", i+1))
})
})
}
PageNumber 和 TotalPages 是占位符,分页完成、布局引擎知道总页数后才展开。不需要 {nb} 哨兵,也不需要 SetY(-15) 把页脚钉在底部 —— 页脚就是一棵树,引擎每页自动为它预留空间。
Before / After 5:给 HTTP handler 返回字节流
实际生产 gofpdf 代码大多不写文件,而是写到 io.Writer —— 一般是返回 application/pdf 的 http.ResponseWriter。这组里 gpdf 的 API 最接近 gofpdf。
Before — gofpdf:
func handler(w http.ResponseWriter, r *http.Request) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetFont("Arial", "", 12)
pdf.Cell(0, 10, "生成时间: "+time.Now().Format(time.RFC3339))
w.Header().Set("Content-Type", "application/pdf")
if err := pdf.Output(w); err != nil {
http.Error(w, err.Error(), 500)
}
}
After — gpdf:
func handler(w http.ResponseWriter, r *http.Request) {
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("生成时间: " + time.Now().Format(time.RFC3339))
})
})
w.Header().Set("Content-Type", "application/pdf")
if err := doc.Render(w); err != nil {
http.Error(w, err.Error(), 500)
}
}
形态一样。doc.Render(w) 直接把 PDF 流进响应。如果要带 Content-Length,先调 Generate() 拿字节切片再 len()。
"够快"到底是多快?
gpdf 在实际负载上比 gofpdf 快约 10 倍。下表数据来自 _benchmark/benchmark_test.go,Apple M1、Go 1.25。
| 基准 | gpdf | gofpdf | gopdf | Maroto v2 |
|---|---|---|---|---|
| 单页 | 13 µs | 132 µs | 423 µs | 237 µs |
| 4×10 表格 | 108 µs | 241 µs | 835 µs | 8.6 ms |
| 100 页文档 | 683 µs | 11.7 ms | 8.6 ms | 19.8 ms |
| 复杂 CJK 发票 | 133 µs | 254 µs | 997 µs | 10.4 ms |
不是合成数。表格基准是 4 列 10 行发票明细,100 页基准是带重复页眉和页码的报表 —— 正是生产代码的形态。
顺便说一下含义。13 µs/单页 = 单核每秒 7.5 万张 hello-world PDF;108 µs/带表页 = 每秒约 9,000 张发票。重点不是炫耀,而是你可以不用再纠结 "PDF 生成要不要缓存?是不是得丢进异步队列?" 这种事。多数场景下,请求链路里同步生成就够。
迁移要放弃的东西
指南不该掩盖真实差距。gpdf 目前相对 gofpdf 欠缺的:
- 任意角度的直线、贝塞尔曲线、复杂路径。
c.Line()画的是跨列的水平线。CAD 图或自定义图表几何,gpdf 还没到。(但图表以图像形式预渲染嵌入是完全没问题的。) SetXY与绝对光标。能用page.Absolute(x, y, fn)做绝对定位,但如果现有代码是 2,000 行SetXY+Cell,迁移基本等同重写。好处是重写后通常只有原来一半长。- AcroForm 可填写表单。gpdf 还不生成可填字段。如果你的 PDF 是给用户在阅读器里填写的模板,暂时留在支持 AcroForm 的库上。
- 注释与书签。基础大纲有,丰富注释没有。
如果这些都不卡你,迁移一气呵成。如果卡,开一个 Issue —— 路线图是由需求驱动的。
FAQ
gpdf 是 gofpdf 的 fork 吗? 不是。gpdf 是纯 Go 从零重写的。PDF 线格式、布局引擎、TrueType 子集化器 —— 全部独立实现,与 gofpdf 及其 fork 没有代码血缘。必须重写而非 fork,是因为 gofpdf 的架构建立在"单一可变光标"上,声明式栅格塞不进去而不把现有调用全部打碎。
gpdf 有外部依赖吗?
核心零依赖。go get github.com/gpdf-dev/gpdf 后 go mod graph | grep gpdf 只返回一行。gpdf-pro 扩展(HTML→PDF、AES 加密、数字签名、PDF/A)会因 HTML 解析引入 golang.org/x/net,但那是可选,迁移用不到。
CGO 呢?gofpdf 是 CGO-free 的,gpdf 呢?
同样纯 Go、无 CGO。GOOS=linux GOARCH=arm64 go build 就能交叉编译出静态二进制。这对 distroless 和 Alpine 镜像尤其重要 —— 没有 CGO 工具链,镜像大小能差一半。
我现有 gofpdf 代码里到处是 SetXY 绝对定位。能不重写就迁过来吗?
可以包一层 page.Absolute(x, y, fn) 做类似感觉。但如果整个代码围绕光标操作组织,迁到布局引擎模型是心智切换而非语法切换。多数团队发现重写后比原版更短。
增值税发票、合规归档怎么办?
时间戳和数字签名在 gpdf-pro 里实现中。有具体需求,开 Issue 提优先级。
要是 go-pdf/fpdf 又恢复维护呢? 那就多一个选择。gpdf 的赌注不是"gofpdf 永远归档",而是"基于光标 + 单字节字体 + 无原生 CJK 的架构,谁维护都是死胡同"。2026 年的 PDF 生成更像搭网页而非驱动绘图仪,API 应该反映这一点。
试试 gpdf
gpdf 是 Go 的 PDF 生成库。MIT 协议、零外部依赖、原生 CJK。
go get github.com/gpdf-dev/gpdf
延伸阅读
- 12 栅格在 gpdf 里是怎么工作的 (即将发布)
- 如何在 gpdf 里嵌入中文字体 (即将发布)
- Quickstart —— 5 分钟上手,含
go.mod