全部文章

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/gofpdf2021 年 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 的主要用途集中在:

  1. 发票、收据、送货单 —— 页眉、客户信息、明细表、合计、页脚
  2. 报表 —— 重复页眉页脚、页码、图表(以图像形式插入)的多页文档
  3. 证书和表单 —— 固定位置文字叠加在模板之上
  4. CJK 文档 —— 中日韩文发票和物流标签

前三类 gpdf 的 builder API 都能直接覆盖。第四类 CJK 才是 gpdf 相对 gofpdf 最大的差距 —— gofpdf 要求调用 AddUTF8Font,管理 TTF 文件路径,并祈祷你的文本不会超出基本字符面。gpdf 从设计之初就把 CJK 当作一等公民:注册 TrueType 字体,写中文,输出 PDF。

API 对照表

下表是速查表。后面章节逐个讲 5 组具体的 Before/After。

你想做什么gofpdfgpdf
创建文档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)
输出到 Writerpdf.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))
        })
    })
}

PageNumberTotalPages 是占位符,分页完成、布局引擎知道总页数后才展开。不需要 {nb} 哨兵,也不需要 SetY(-15) 把页脚钉在底部 —— 页脚就是一棵树,引擎每页自动为它预留空间。

Before / After 5:给 HTTP handler 返回字节流

实际生产 gofpdf 代码大多不写文件,而是写到 io.Writer —— 一般是返回 application/pdfhttp.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。

基准gpdfgofpdfgopdfMaroto v2
单页13 µs132 µs423 µs237 µs
4×10 表格108 µs241 µs835 µs8.6 ms
100 页文档683 µs11.7 ms8.6 ms19.8 ms
复杂 CJK 发票133 µs254 µs997 µs10.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/gpdfgo 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

⭐ 在 GitHub 加星 · 阅读文档

延伸阅读

  • 12 栅格在 gpdf 里是怎么工作的 (即将发布)
  • 如何在 gpdf 里嵌入中文字体 (即将发布)
  • Quickstart —— 5 分钟上手,含 go.mod