全部文章

从 signintech/gopdf 迁移到 gpdf:少写坐标计算

signintech/gopdf 能用,但每个单元格、每条线、每个页眉都是 (x, y) 计算。本文逐项映射 gopdf API 到 gpdf — 同样是 Go,不再写坐标。

TL;DR

gpdf 是带 12 列布局引擎的纯 Go PDF 库。signintech/gopdf 是 PDF 坐标系的低层绑定。如果你已经用 gopdf 一段时间,代码库现在大部分由 SetXYCell 和宽度算术构成,这篇文章展示这些调用在布局引擎下会塌缩成什么。

上周我和一位同行重构基于 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/gopdfgpdf
构造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 + SetXYc.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)
输出到 writerpdf.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)))
        })
    })
})

PageNumberTotalPages 是占位符。布局引擎先分页,解析总数,再写入。一遍,无手动计数,无重复渲染。

中文字体: 不用手动子集化

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。

基准gpdfsignintech/gopdfgofpdfMaroto v2
单页13 µs423 µs132 µs237 µs
4×10 发票表格108 µs835 µs241 µs8.6 ms
100 页报告683 µs8.6 ms11.7 ms19.8 ms
复杂 CJK 发票133 µs997 µs254 µs10.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.A4document.Letterdocument.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

⭐ Star on GitHub · 阅读文档

接下来读