从 signintech/gopdf 迁移到 gpdf:少写坐标计算
signintech/gopdf 能用,但每个单元格、每条线、每个页眉都是 (x, y) 计算。本文逐项映射 gopdf API 到 gpdf — 同样是 Go,不再写坐标。
TL;DR
gpdf 是带 12 列布局引擎的纯 Go PDF 库。signintech/gopdf 是 PDF 坐标系的低层绑定。如果你已经用 gopdf 一段时间,代码库现在大部分由 SetXY、Cell 和宽度算术构成,这篇文章展示这些调用在布局引擎下会塌缩成什么。
上周我和一位同行重构基于 signintech/gopdf 的发票生成器。五年累积。绘制明细行表格的函数有 280 行。其中真正干活的大约 40 行 — 格式化金额、格式化日期、按行重复。剩下 240 行在做坐标计算: 计算 x 位置、追踪 y、调用 SetXY、调用 Cell、调用 Br、用 Line(x1, y1, x2, y2) 画边框、判断行是否能放进当前页、放不进时手动 AddPage 并重绘表头。
这就是生产环境的 gopdf。它不是糟糕的库 — 是薄、快、无 CGO 的 PDF 成像模型绑定,名副其实。有光标、有坐标,而工程师扮演布局引擎。
本文按函数把 gopdf API 映射到 gpdf。结论已在标题: 大多数代码行会消失,因为它们做的是运行时可以替你做的布局数学。
signintech/gopdf 的优点 — 以及它不是什么
进入「迁移走」叙事之前先讲清楚,gopdf 有真本事。
维护活跃。纯 Go (无 CGO),交叉编译和 Alpine 镜像都顺畅。支持 TrueType 字体包括 CJK。输出快 — gopdf 在成像原语上和 gpdf 是同一档,因为两者都直接写 PDF wire format,没有重型引擎。API 直接映射底层 PDF 模型: 有当前点,你移动它,你在那里画。如果你已经按 PDF 坐标思考,gopdf 用着舒服。
不舒服的地方是它没有布局系统。没有行、列、flex、网格的概念。没有自动分页: 内容超过下边距,内容就超过下边距 (或干脆跑出页面),除非你自己调用 AddPage。表格不是原语 — 是每个项目都重写一次的模式: 逐单元格 Cell 调用、手画边框、自己写分页逻辑。
对单页证书或非常受控的固定模板表单,光标模型够用。对发票、报表、对账单、任何含可变长内容的文档,坐标计算随文档面积线性增长。这才是 gpdf 针对的工作负载。
心智模型转换
这是真正改变代码读感的部分。gpdf 有 gopdf 没有的两个想法。
声明式树。 你不告诉渲染器东西放哪。你描述一棵 page → row → column → content 的树,布局引擎单遍解析位置。没有要推进的光标。两次连续 r.Col(...) 互不知情。
12 列网格。 每行隐式宽 12 单位。你按列分配它们: r.Col(8, ...) 占 2/3,r.Col(4, ...) 占 1/3。和 Bootstrap 与 Tailwind 在 HTML 中用的同一思想,搬到 PDF。你不再算 pageWidth - leftMargin - rightMargin 再除以 4。你写 r.Col(3, ...) 四次。
这两点就移除了大部分数学。后面的 Before/After 对子全部以同样的方式塌缩: 推进光标的命令式循环变成一棵小的声明式树。
API 映射表
先发参考表。后面五对具体对照。
| 你想做 | signintech/gopdf | gpdf |
|---|---|---|
| 构造 | pdf := gopdf.GoPdf{}; pdf.Start(gopdf.Config{...}) | doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4), ...) |
| 设置页面大小 | Config{PageSize: gopdf.PageSizeA4} | gpdf.WithPageSize(document.A4) |
| 添加页面 | pdf.AddPage() | page := doc.AddPage() |
| 移动光标 | pdf.SetX(40); pdf.SetY(80) (到处) | (无光标) |
| 单行文本 | pdf.SetXY(x, y); pdf.Cell(nil, "hi") | c.Text("hi") (在列内) |
| 自动换行文本 | pdf.MultiCell(&gopdf.Rect{W: 200, H: 100}, body) | c.Text(body) (自动换行) |
| 换行 | pdf.Br(20) | (行间隐式;需要时 c.Spacer(document.Mm(4))) |
| 字体注册 | pdf.AddTTFFont("noto", "fonts/Noto.ttf") | gpdf.WithFont("Noto", ttfBytes) (构建时) |
| 设置当前字体 | pdf.SetFont("noto", "", 14) | 每段文本 template.FontFamily("Noto"), template.FontSize(14) |
| 颜色 | pdf.SetTextColor(26, 35, 126) | template.TextColor(pdf.RGBHex(0x1A237E)) |
| 水平线 | pdf.Line(40, 100, 555, 100) | c.Line(template.LineColor(pdf.Gray(0.7))) |
| 矩形 | pdf.RectFromUpperLeftWithStyle(x, y, w, h, "FD") | c.Box(template.BgColor(...), template.Border(...)) |
| 图像 | pdf.Image("logo.png", x, y, &gopdf.Rect{W: 100, H: 50}) | c.Image(imgBytes, template.FitWidth(document.Mm(35))) |
| 手写表格 | 数十次 Cell + Line + SetXY | c.Table(headers, rows, template.ColumnWidths(...)) |
| 页眉 / 页脚 | pdf.AddHeader(fn) / pdf.AddFooter(fn) | doc.Header(fn) / doc.Footer(fn) |
| 页码 | 从自维护计数器格式化 "Page %d of %d" | c.PageNumber() / c.TotalPages() (占位符) |
| 加密 | Config{Protection: PDFProtectionConfig{...}} | gpdf.WithEncryption(gpdf.AES256, "user", "owner", perms) |
| 输出 | pdf.WritePdf("out.pdf") | data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644) |
| 输出到 writer | pdf.Write(w) / pdf.ToBuffer() | doc.Render(w) |
两个结构性变化。第一,光标消失。表中标 (到处) 的行不是夸张 — 真实生产 gopdf 代码中 SetXY 出现次数超过 Cell。在 gpdf 这些全部塌缩为零。第二,像素变成百分比。Rect{W: 200, H: 100} 变成「这一列在容器的 12 单位中占 4」。把同一列放到半宽行内,无需改动比例自动保持。
Before / After 1: hello world
最短可能的差异。看右侧少了什么。
Before — signintech/gopdf:
package main
import (
"log"
"github.com/signintech/gopdf"
)
func main() {
pdf := gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
pdf.AddPage()
if err := pdf.AddTTFFont("helvetica", "fonts/Helvetica.ttf"); err != nil {
log.Fatal(err)
}
if err := pdf.SetFont("helvetica", "", 24); err != nil {
log.Fatal(err)
}
pdf.SetX(40)
pdf.SetY(80)
if err := pdf.Cell(nil, "Hello, World!"); err != nil {
log.Fatal(err)
}
if err := pdf.WritePdf("hello.pdf"); err != nil {
log.Fatal(err)
}
}
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)
}
}
少了两样。Helvetica 是标准 14 字体之一,gpdf 自带,运行时无需 TTF 文件。SetX(40); SetY(80) 没了 — 行自动落在页面边距内。多了一个跨满 12 单位的单列行。这套脚手架对 "Hello, World!" 显得重,但同样的脚手架支撑 100 页报表,这正是要点。
Before / After 2: 4 列表头行
这是坐标计算最显眼的地方。要一条横贯页面的 4 等宽单元格表头: 页宽减边距,除以 4。在 gopdf 你写这个除法。在 gpdf 你把 12 单位分成 4 份。
Before — signintech/gopdf:
const (
pageWidth = 595.28 // A4 (pt)
leftMargin = 40.0
rightMargin = 40.0
rowY = 100.0
rowH = 24.0
)
contentWidth := pageWidth - leftMargin - rightMargin // 515.28
colW := contentWidth / 4 // 128.82
pdf.SetFont("helvetica-bold", "", 11)
pdf.SetFillColor(26, 35, 126)
pdf.SetTextColor(255, 255, 255)
headers := []string{"描述", "数量", "单价", "金额"}
for i, h := range headers {
x := leftMargin + colW*float64(i)
pdf.RectFromUpperLeftWithStyle(x, rowY, colW, rowH, "F")
pdf.SetXY(x+6, rowY+7)
if err := pdf.Cell(nil, h); err != nil {
log.Fatal(err)
}
}
pdf.SetTextColor(0, 0, 0)
四个常数,一次宽度减法,一次除法,带 colW*float64(i) 的循环 — 那个 float 转换只因为 Go 的 * 不会自动把 int 提升为 float64。gpdf 版本里这些都不存在。
After — gpdf:
page.AutoRow(func(r *template.RowBuilder) {
headers := []string{"描述", "数量", "单价", "金额"}
for _, h := range headers {
r.Col(3, func(c *template.ColBuilder) {
c.Box(
template.BgColor(pdf.RGBHex(0x1A237E)),
template.Padding(document.Mm(2), document.Mm(3)),
)
c.Text(h,
template.Bold(), template.FontSize(11),
template.TextColor(pdf.White),
)
})
}
})
r.Col(3, ...) 四次合计 12。宽度由网格处理。从 A4 换到 Letter,缩小边距,这段代码完全不依赖 pageWidth,排布依然正确。要让第一列宽度是其他三列的两倍,把那一列改成 r.Col(6, ...),另一列改成 r.Col(2, ...) 即可。无需算术。
Before / After 3: 跨页的发票表格
重头戏。在 gopdf 中绘制跨多页表格基本是簿记: 追踪当前 y、画每一行、检查下一行是否容得下、容不下就调用 AddPage 并重绘表头。状态机就在你的代码里。
Before — signintech/gopdf:
func drawInvoiceTable(pdf *gopdf.GoPdf, items [][4]string) error {
const (
pageH = 841.89 // A4 高度
bottomLimit = pageH - 40
rowH = 22.0
leftX = 40.0
)
cols := []float64{260, 80, 80, 95}
drawHeader := func(y float64) float64 {
pdf.SetFont("helvetica-bold", "", 11)
pdf.SetFillColor(26, 35, 126)
pdf.SetTextColor(255, 255, 255)
x := leftX
for i, h := range []string{"描述", "数量", "单价", "金额"} {
pdf.RectFromUpperLeftWithStyle(x, y, cols[i], rowH, "F")
pdf.SetXY(x+6, y+7)
if err := pdf.Cell(nil, h); err != nil {
log.Println(err)
}
x += cols[i]
}
pdf.SetTextColor(0, 0, 0)
pdf.SetFont("helvetica", "", 11)
return y + rowH
}
y := drawHeader(100)
for _, row := range items {
if y+rowH > bottomLimit {
pdf.AddPage()
y = drawHeader(60)
}
x := leftX
for i, cell := range row {
pdf.RectFromUpperLeftWithStyle(x, y, cols[i], rowH, "D")
pdf.SetXY(x+6, y+7)
if err := pdf.Cell(nil, cell); err != nil {
return err
}
x += cols[i]
}
y += rowH
}
return nil
}
表格函数 30 行,只有 5 行关于数据。其余是布局: 硬编码高度、硬编码下界、分页后重绘表头的闭包、两个 for 循环、每单元格两次光标推进。这是 gopdf 表格的中位数。
After — gpdf:
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table(
[]string{"描述", "数量", "单价", "金额"},
items, // [][]string
template.ColumnWidths(55, 15, 15, 15),
template.TableHeaderStyle(
template.Bold(),
template.TextColor(pdf.White),
template.BgColor(pdf.RGBHex(0x1A237E)),
),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)
})
})
就这些。自动分页。正文延续到的页面自动重复表头。条纹行只需一个选项。列宽是容器百分比,所以同一表格放进 r.Col(6, ...) 会按比例显示一半大小,无需重写。25 行的 gopdf 簿记函数消失。
一个具体数字。100 行发票渲染在 gpdf 中是 108 µs,在 signintech/gopdf 中约 2.4 ms — gopdf 数字取决于你写的逐单元格模式因此会变。倍数不是重点;函数本身消失才是。
Before / After 4: 段落旁边的图像
常见模式: 左边公司 logo,右边地址块。
Before — signintech/gopdf:
const (
leftX = 40.0
rightX = 380.0
blockY = 50.0
)
if err := pdf.Image("logo.png", leftX, blockY, &gopdf.Rect{W: 100, H: 60}); err != nil {
log.Fatal(err)
}
pdf.SetFont("helvetica-bold", "", 14)
pdf.SetXY(rightX, blockY)
if err := pdf.Cell(nil, "ACME 公司"); err != nil {
log.Fatal(err)
}
pdf.SetFont("helvetica", "", 10)
pdf.SetXY(rightX, blockY+20)
pdf.Cell(nil, "上海市浦东新区世纪大道 100 号")
pdf.SetXY(rightX, blockY+34)
pdf.Cell(nil, "200120")
pdf.SetXY(rightX, blockY+48)
pdf.Cell(nil, "[email protected]")
六个显式 y 坐标,右侧块从 rightX = 380 开始 — 因为有人决定 logo 宽 100,右侧块需要 240 像素间隔。把 logo 移到右侧每个数字都要变。
After — gpdf:
//go:embed logo.png
var logoData []byte
page.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) {
c.Image(logoData, template.FitWidth(document.Mm(35)))
})
r.Col(8, func(c *template.ColBuilder) {
c.Text("ACME 公司", template.Bold(), template.FontSize(14))
c.Text("上海市浦东新区世纪大道 100 号")
c.Text("200120")
c.Text("[email protected]")
})
})
两列,4 + 8 = 12。图像按固定宽度适配,高度按宽高比 gpdf 自己算。每个 c.Text 流到上一行下方 — 没有 Br,没有 y 算术。要把 logo 放右边就互换列顺序。
Before / After 5: 页脚的页码
在 gopdf 中你自维护计数,因为渲染单遍,画第一个页脚时总数未知。多数代码库做两遍渲染: 第一遍数页数,第二遍把总数烧进去。
Before — signintech/gopdf:
totalPages := 0
pdf.AddFooter(func() {
totalPages++
})
buildContent(&pdf)
finalTotal := totalPages
pdf2 := gopdf.GoPdf{}
pdf2.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
pageNum := 0
pdf2.AddFooter(func() {
pageNum++
pdf2.SetFont("helvetica", "", 8)
pdf2.SetXY(40, 800)
pdf2.Cell(nil, fmt.Sprintf("第 %d 页 / 共 %d 页", pageNum, finalTotal))
})
buildContent(&pdf2)
pdf2.WritePdf("report.pdf")
如果维护过 gopdf 代码,你写过这段。FAQ 里没有,但这是不解析输出就拿到诚实「第 X 页 / 共 Y 页」的唯一办法。
After — gpdf:
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) {
c.Stack(template.AlignRight(), func(c *template.ColBuilder) {
c.Text("第 ", template.Inline())
c.PageNumber(template.Inline())
c.Text(" 页 / 共 ", template.Inline())
c.TotalPages(template.Inline())
c.Text(" 页", template.Inline())
}, template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
})
})
})
PageNumber 和 TotalPages 是占位符。布局引擎先分页,解析总数,再写入。一遍,无手动计数,无重复渲染。
中文字体: 不用手动子集化
signintech/gopdf 也支持 CJK,但要自己管字符集。注册 TTF、设置字符映射,文本含未在子集中的字形就会显示豆腐方块。gpdf 的 TrueType 子集化器走 cmap (格式 4、6、12),只嵌入实际使用的字形 — 无需手动子集列表。
//go:embed NotoSansSC-Regular.ttf
var notoSC []byte
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
gpdf.WithFont("NotoSansSC", notoSC),
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("上海市浦东新区世纪大道 100 号")
})
})
200 字中文发票产生 ~30 KB 字体子集,而非 4 MB 完整嵌入。
基准测试
同硬件、同负载,Apple M1 配 Go 1.25。
| 基准 | gpdf | signintech/gopdf | gofpdf | Maroto v2 |
|---|---|---|---|---|
| 单页 | 13 µs | 423 µs | 132 µs | 237 µs |
| 4×10 发票表格 | 108 µs | 835 µs | 241 µs | 8.6 ms |
| 100 页报告 | 683 µs | 8.6 ms | 11.7 ms | 19.8 ms |
| 复杂 CJK 发票 | 133 µs | 997 µs | 254 µs | 10.4 ms |
数字来自 gpdf/_benchmark/benchmark_test.go。
每核 每张表格页 108 µs = 每秒约 9000 张发票。多数工作负载下 PDF 生成可留在请求路径上。
gopdf 有但 gpdf 没有的
诚实环节。如果你的 gopdf 用法依赖以下,迁移单靠本文不够。
ImportPage。 从已有 PDF 导入一页并在上面盖章。gpdf 的 overlay (gpdf.Overlay) 处理常见场景,但不公开同样的UseImportedTemplate原语。- 多边形和椭圆原语。 gpdf 不把任意路径作为一等公民。数据可视化用图表库渲染成 PNG/SVG 后嵌入。
- 直接光标定位。 真要像素精确放置 (例如恰好在
(420, 240)盖章) 时用page.Absolute(x, y, fn),但这是逃生口。 PlaceHolderText/FillInPlaceHoldText。 通用「先留位、后填」机制 gpdf 还没有;PageNumber/TotalPages占位符仅覆盖页码场景。
对于发票、对账单、报表、证书、合同、收据、运输标签、装箱单、CJK 文档 — gopdf 的多数实际负载 — 替换是完整的。
FAQ
gpdf 是 signintech/gopdf 的分支吗? 不是。gpdf 是纯 Go 的全新实现。无共享代码,无血缘。
两个都是纯 Go、CGO-free。换的实利是什么? 布局引擎。前面迁移段落 80% 是删坐标计算,这才是日常代码读感的差别。基准是次要。MIT 许可证两边一致,所以许可证不是因素。
能渐进迁移吗?
能。两库不冲突。各自产生独立 []byte 输出。一段 gpdf 一段 gopdf,用 gpdf.Merge(a, b) 拼接。但实践中按文档整体迁移更省心 — 一份文件里两套心智模型容易混乱。
已有代码用 pdf.Image(path, ...) 从磁盘读 logo,必须 embed 吗?
不必。c.Image(imageBytes, ...) 接收字节,可以 os.ReadFile 在运行时读。但 //go:embed 是更好的默认: 容器镜像无需可写文件系统,生产中资源不会丢失。
gopdf.PageSizeA4 等页面尺寸常量?
gpdf 的 document.A4、document.Letter、document.Legal 等覆盖同一集合。自定义尺寸用 document.PageSize(document.Mm(210), document.Mm(297))。
用 pdf.Rotate 做斜向水印,有等价吗?page.Absolute(x, y, fn) 接受旋转选项,典型「页面对角水印」一次 page.Absolute 调用即可。
有自动重写工具吗? 还没有。简单部分映射机械,表格重写是结构性的 — 删除簿记代码而不是翻译。每种文档手动迁移大约几小时。
试用 gpdf
gpdf 是 Go 的 PDF 生成库。MIT 许可证、零依赖、原生 CJK 支持、12 列网格布局。
go get github.com/gpdf-dev/gpdf