unipdf 是 AGPL 或付费:迁移到 gpdf 的完整指南
UniDoc 的 unipdf 必须选 AGPL v3 或按开发者收费的商用许可。迁移到 MIT、零依赖、无许可证密钥的 gpdf。
TL;DR
gpdf 是一个采用 MIT 许可证、零外部依赖、无需许可证注册 的纯 Go PDF 库。如果你在用 unidoc/unipdf 是因为没有别的库能搞定 CJK 或 AcroForm,但 AGPL 条款让法务部卡住分发,商用许可的成本又难以说服管理层 — 这篇文章是 unipdf creator API 到 gpdf 的迁移地图。
上个季度,一个金融科技朋友的团队把 github.com/unidoc/unipdf/v3 提交到了 OSS 审批流程。第二天单子被打回,AGPL-3.0 旁边一个红 ✗,加上法务一句:"不能链接到闭源产品中分发。请购买商用许可或移除。"商用报价按每个开发者每年算,12 人团队一总,所有人又重新打开了搜索结果。
这是 unipdf README 不会写的另一面。unipdf 技术上很出色 — 成熟、功能全、维护积极。但它是 双许可证:开源用途用 AGPL v3,其他用途必须 付费商用许可。AGPL v3 是常见 copyleft 中最严的之一。如果你把 unipdf 链接进一个用户通过网络访问的服务,§13 条要求你必须公开整个对应源码。大多数公司的法务都会说不。
如果你已经有 unipdf 代码在生产,许可证在审计中被卡或即将续费,这篇是迁移地图。如果你刚开始项目因为 unipdf 文档最完善就装了它,这篇是不带账单关系的替代方案。
"AGPL 或付费"在实际操作中是什么意思
很多 Go 库随便贴个 "AGPL" 标签,没真考虑过含义。unipdf 不是这样。仓库的许可证文件就是纯 AGPL v3,README 明确说商用必须密钥,二进制本身也强制执行 — 启动时不注册许可证就调用 unipdf API,要么报错要么每页输出加水印。
你大致会处于以下三种模式之一:
- AGPL 模式。你以 AGPL v3 公开自己的代码。所有接触 unipdf 的字节,加上链接到它的所有代码,必须能让通过网络访问该服务的任何人获取。对内部工具和 SaaS 产品来说,这基本不可行。
- 商用模式。你按每开发者每年付费给 UniDoc。价格变化,最近公开报价大约每席每年四位数美元,包含一个 metering 或许可证密钥注册调用,每个二进制启动时都要执行。密钥被当成 secret 处理,意味着它要进 secret manager 并注入每个容器。
- 试用 / 评估模式。限时免费。输出带水印。生产环境用不了。
这三种模式本身都不是错的。UniDoc 是真公司、真工程师维护的产品,价格反映真实成本。问题在于这个许可证决策渗透到每一层:法务审查、密钥轮换、财务续约、部署面 (每个容器都要密钥)。gpdf 因为是 MIT,把这一整列从你的电子表格里抹掉了。
你失去什么,保留什么
进入 API 之前先实事求是。unipdf 有些事 gpdf 做不到:
| 能力 | unipdf | gpdf |
|---|---|---|
| PDF 生成 | ✅ | ✅ |
| TrueType / CJK 字体 | ✅ | ✅ (无 CGO,自动子集化) |
| AES-128/256 加密 | ✅ | ✅ (ISO 32000-2 Rev 6,纯 Go) |
| PKCS#7 / PAdES 签名 | ✅ | ✅ (支持 RFC 3161 TSA) |
| PDF/A-1b/2b | ✅ | ✅ |
| AcroForm — 填写已有字段 | ✅ | ✅ (仅扁平化,不能创建新字段) |
| AcroForm — 创建新字段 | ✅ | ❌ |
| PDF 解析 / 文本抽取 | ✅ | ❌ (gpdf 专注于生成) |
| OCR | ✅ | ❌ |
| PDF 涂黑 (redaction) | ✅ | ❌ |
| HTML 渲染 | 部分 | ❌ (用单独的渲染器,再合并) |
如果你需要 PDF 解析、OCR 或 redaction,这次迁移走不到底。要么只在那些代码路径里保留 unipdf (那部分二进制还得买商用许可),要么读取侧换用专门的解析库。对 生成、加密、签名、字体、CJK 这些路径 — 也就是大多数 unipdf 账单实际买的东西 — gpdf 是完整替代。
删除许可证注册代码
这是整个迁移最小的 diff,也是让其他工作变得真实的一步。unipdf 二进制启动时必须注册密钥,有几种写法:
// API key (metered)
import "github.com/unidoc/unipdf/v3/common/license"
func init() {
if err := license.SetMeteredKey(os.Getenv("UNIDOC_API_KEY")); err != nil {
log.Fatal(err)
}
}
// 离线许可证文件
func init() {
licenseKey, _ := os.ReadFile("/etc/unidoc/license.txt")
if err := license.SetLicenseKey(string(licenseKey), "Acme Corp"); err != nil {
log.Fatal(err)
}
}
gpdf 没有对应的东西。整个 init() 块删掉。把 UNIDOC_API_KEY 从 secret manager、CI 变量、容器 manifest 里拿掉。从镜像中移除许可证文件。要 import 的只有 github.com/gpdf-dev/gpdf,唯一的要求是在某处调用 gpdf.NewDocument。
就是这样。也是迁移完成的判定标准:完成后 grep -r unidoc . 应该返回 0 行。
API 映射表
下表是速查表。后续章节是 5 个具体配对。unipdf 的高级构建器叫 Creator,gpdf 叫 Document。形状足够接近,大多数代码靠肉眼对照就能转换。
| 你想做的事 | unipdf (creator) | gpdf |
|---|---|---|
| 创建构建器 | c := creator.New(); c.SetPageSize(creator.PageSizeA4) | doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4)) |
| 设置边距 | c.SetPageMargins(L, R, T, B) | gpdf.WithMargins(document.UniformEdges(document.Mm(20))) |
| 新建页面 | c.NewPage() | page := doc.AddPage() |
| 单行文本 | p := c.NewParagraph("hi"); c.Draw(p) | c.Text("hi") (在列内) |
| 自动换行文本 | p := c.NewStyledParagraph(); p.SetText(...); c.Draw(p) | c.Text(body) (自动换行) |
| 字体注册 | model.NewCompositePdfFontFromTTFFile(path) | gpdf.WithFont("Name", ttfBytes) (构建时) |
| 设置文本字体 | style.Font = font; style.FontSize = 12 | template.FontFamily("Name"), template.FontSize(12) per-text |
| 颜色 | style.Color = creator.ColorRGBFromHex("#1A237E") | template.TextColor(pdf.RGBHex(0x1A237E)) |
| 表格 | t := c.NewTable(4); t.SetColumnWidths(...); c.Draw(t) | c.Table(headers, rows, template.ColumnWidths(...)) |
| 图片 | img, _ := c.NewImageFromFile(path); img.ScaleToWidth(w); c.Draw(img) | c.Image(imgBytes, template.FitWidth(document.Mm(50))) |
| 页眉 / 页脚 | c.DrawHeader(fn) / c.DrawFooter(fn) | doc.Header(fn) / doc.Footer(fn) |
| 页码 | 在 DrawFooter 调用中手动追踪 | c.PageNumber() / c.TotalPages() (占位符) |
| 加密 | model.PdfWriter + Encrypt 重新编码 | gpdf.WithEncryption(gpdf.AES256, "user", "owner", perms) |
| 签名 | model.NewPdfAppender(...).Sign(...) | gpdf.SignDocument(pdfBytes, signer, opts) |
| 许可证注册 | license.SetMeteredKey(...) 在 init() | (无 — 删除即可) |
| 输出到文件 | c.WriteToFile("out.pdf") | data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644) |
| 输出到 writer | c.Write(w) | doc.Render(w) |
两个结构性转变要记住。unipdf 的 creator 是 有状态 的:你构建一个 Paragraph 或 Table,然后调用 c.Draw(thing) 提交。gpdf 是 声明式 的:描述一棵行和列的树,让布局引擎放置。第二个是 gpdf 像 Bootstrap 一样有 12 栅格。每行隐式宽 12 单位,用 r.Col(n, fn) 消费。一旦不再用毫米追列宽,多数布局会缩成两三行。
Before / After 1: 最小可能的 PDF
"hello world"配对。unipdf 版本不长,只是因为许可证调用而多了仪式感。
Before — unipdf:
package main
import (
"log"
"os"
"github.com/unidoc/unipdf/v3/common/license"
"github.com/unidoc/unipdf/v3/creator"
)
func init() {
if err := license.SetMeteredKey(os.Getenv("UNIDOC_API_KEY")); err != nil {
log.Fatal(err)
}
}
func main() {
c := creator.New()
c.SetPageSize(creator.PageSizeA4)
p := c.NewParagraph("Hello, World!")
p.SetFontSize(24)
if err := c.Draw(p); err != nil {
log.Fatal(err)
}
if err := c.WriteToFile("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)
}
}
三处区别。init() 块没了 — 没密钥,没环境变量。构建用选项而不是修改构建器。文本住在行和列里,而不是后面再 draw 的独立 Paragraph。栅格在做布局,不需要选坐标。
Before / After 2: 带样式的发票明细表格
unipdf 的 creator API 在做表格时会变长。构造一个 Table,按绝对比例 SetColumnWidths,逐个用 NewCell / SetContent 构建单元格,手动配置每个单元格的边框和对齐。
Before — unipdf:
table := c.NewTable(4)
table.SetColumnWidths(0.5, 0.15, 0.15, 0.2)
headerStyle := c.NewTextStyle()
headerStyle.Font, _ = model.NewStandard14Font("Helvetica-Bold")
headerStyle.FontSize = 11
headerStyle.Color = creator.ColorWhite
drawHeaderCell := func(text string) {
cell := table.NewCell()
cell.SetBackgroundColor(creator.ColorRGBFromHex("#1A237E"))
cell.SetBorder(creator.CellBorderSideAll, creator.CellBorderStyleSingle, 0.5)
p := c.NewStyledParagraph()
chunk := p.Append(text)
chunk.Style = headerStyle
cell.SetContent(p)
}
for _, h := range []string{"项目", "数量", "单价", "金额"} {
drawHeaderCell(h)
}
for _, row := range items {
for _, cellText := range row {
cell := table.NewCell()
cell.SetBorder(creator.CellBorderSideAll, creator.CellBorderStyleSingle, 0.3)
p := c.NewParagraph(cellText)
p.SetFontSize(11)
cell.SetContent(p)
}
}
if err := c.Draw(table); err != nil {
log.Fatal(err)
}
边框、每单元格内容、画表头的循环 — 全是机械操作。
After — gpdf:
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table(
[]string{"项目", "数量", "单价", "金额"},
[][]string{
{"前端开发", "40 小时", "¥1,500", "¥60,000"},
{"后端开发", "60 小时", "¥1,500", "¥90,000"},
{"UI 设计", "20 小时", "¥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 是 该表格所在列宽度的百分比,而不是页面的绝对比例。把同一表格放进 r.Col(6, ...),百分比依然成立 — 表格占据行的一半,列按比例重新分配。分页自动处理;如果正文越过下边距,下一页会自动重复表头,无需手动接线。
一个具体细节值得提。unipdf 的 Table 在 100 行发票运行的基准里大约 8.6 ms 一次渲染。gpdf 的相同负载在 108 µs 完成 — 大约 80 倍 — 因为布局引擎一次测量每行并单遍写出页面,而不是逐单元格地物化 DOM。单张发票看不出差。批量 cron 报表运行下,决定了你需不需要队列。
中国的增值税发票格式 (税号、税率、税额) 在排版上 gpdf 完全能表达。如果还需要 PDF 章 (电子签章) 或时间戳,用 gpdf.SignDocument 的 RFC 3161 TSA 选项处理。
Before / After 3: 中文文本,无需 composite font 仪式
unipdf 支持 CJK,但路径啰嗦。你从磁盘上的 TTF 构造 composite font,设为 style font,传过每个 paragraph。要 fallback 自己接线。
Before — unipdf:
font, err := model.NewCompositePdfFontFromTTFFile("NotoSansSC-Regular.ttf")
if err != nil {
log.Fatal(err)
}
c := creator.New()
c.SetPageSize(creator.PageSizeA4)
style := c.NewTextStyle()
style.Font = font
style.FontSize = 14
p := c.NewStyledParagraph()
p.Append("你好,世界。").Style = style
if err := c.Draw(p); err != nil {
log.Fatal(err)
}
c.WriteToFile("zh.pdf")
TTF 必须在你给的路径上、运行时、运行二进制的主机上存在。容器镜像得带上字体。NewCompositePdfFontFromTTFFile 必须在使用字体的 draw 调用之前发生,意味着它要么放在全局,要么作为依赖到处传。
After — gpdf:
package main
import (
_ "embed"
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
//go:embed NotoSansSC-Regular.ttf
var notoSC []byte
func main() {
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("北京市朝阳区建国门外大街 1 号")
})
})
data, _ := doc.Generate()
if err := os.WriteFile("zh.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
三处区别。字体是 字节,不是路径 — //go:embed 把它编入二进制,运行时镜像不再需要字体目录。字体在 构建时注册一次;不需要逐 paragraph 串 style。gpdf 的 TrueType 子集器理解 CJK cmap 格式 (4、6、12) 和 Identity-H 编码,所以输出 PDF 只携带你实际用到的字形。一份 200 字的中文发票产生约 30 KB 的字体子集,而非 4 MB 的完整嵌入。
关于 Source Han Sans、IPAex Gothic、fallback 链的细节,CJK 字体专题文章里讲。
Before / After 4: 每页页眉与页脚页码
unipdf 的模式是 c.DrawHeader(fn) / c.DrawFooter(fn),两者都接收一个带当前 block 和页码的上下文。页码从上下文的 PageNum / TotalPages 字段取。
Before — unipdf:
c.DrawHeader(func(block *creator.Block, args creator.HeaderFunctionArgs) {
p := c.NewParagraph("ACME 公司")
p.SetFontSize(12)
p.SetPos(40, 30)
block.Draw(p)
})
c.DrawFooter(func(block *creator.Block, args creator.FooterFunctionArgs) {
p := c.NewParagraph(fmt.Sprintf("第 %d 页 / 共 %d 页", args.PageNum, args.TotalPages))
p.SetFontSize(8)
p.SetPos(0, 20)
p.SetTextAlignment(creator.TextAlignmentCenter)
block.Draw(p)
})
页眉 / 页脚都是按绝对位置 draw 的 block。Y 坐标错了、边距错了 — 每次改页面尺寸都要重新对齐。
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) {
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 是占位符,布局引擎在分页之后解析它们。页眉和页脚本身就是树,不是你手动定位的 block。引擎自动为它们在每页保留空间;从 A4 改成 Letter,其他都不用动。
Before / After 5: AES-256 加密
许可证差异最明显的一对。unipdf 的加密走 model.PdfWriter,这算商用使用并触发许可证注册检查。gpdf 的加密在一个函数选项后面,AES-256 (ISO 32000-2 Rev 6) 实现就在开源 MIT 核心里。
Before — unipdf:
// 先用 creator 渲染内容,然后用 model.PdfWriter 重新编码以附加加密。
// 许可证检查在这里触发。
c := creator.New()
// ... 绘制内容 ...
var buf bytes.Buffer
if err := c.Write(&buf); err != nil {
log.Fatal(err)
}
reader, err := model.NewPdfReader(bytes.NewReader(buf.Bytes()))
if err != nil {
log.Fatal(err)
}
writer := model.NewPdfWriter()
encryptOpts := &model.EncryptOptions{Algorithm: model.RC4_128bit, Permissions: model.PermPrinting}
if err := writer.Encrypt([]byte("user-pwd"), []byte("owner-pwd"), encryptOpts); err != nil {
log.Fatal(err)
}
for i := 1; i <= reader.NumPage; i++ {
page, _ := reader.GetPage(i)
writer.AddPage(page)
}
f, _ := os.Create("encrypted.pdf")
defer f.Close()
writer.Write(f)
After — gpdf:
doc := gpdf.NewDocument(
gpdf.WithPageSize(document.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
gpdf.WithEncryption(
gpdf.AES256,
"user-pwd",
"owner-pwd",
gpdf.PermPrinting|gpdf.PermCopyContent,
),
)
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("机密")
})
})
data, _ := doc.Generate()
os.WriteFile("encrypted.pdf", data, 0o644)
一个选项,默认 AES-256,没有单独的 writer pass。整个加密路径在 MIT 核心里 — 同一个模块,同一个 go get。签名一样:gpdf.SignDocument(pdfBytes, signer, gpdf.WithTSA("http://timestamp.digicert.com")) 后处理字节,附加 PKCS#7 + RFC 3161 时间戳,没有额外包,没有密钥注册。
速度怎么样
_benchmark/benchmark_test.go 在 Apple M1 / Go 1.25 上运行的结果。unipdf 不在我们的提交套件里,因为其许可证使分发对比代码变得不便;下面的数字是我们在同一硬件 / 同一负载下针对 unipdf 收集的。
| 基准 | gpdf | unipdf* | gofpdf | Maroto v2 |
|---|---|---|---|---|
| 单页 | 13 µs | ~180 µs | 132 µs | 237 µs |
| 4×10 发票表格 | 108 µs | ~8.6 ms | 241 µs | 8.6 ms |
| 100 页报告 | 683 µs | ~95 ms | 11.7 ms | 19.8 ms |
| 复杂 CJK 发票 | 133 µs | ~12 ms | 254 µs | 10.4 ms |
* unipdf 数字是单独运行收集的,同样在 Apple M1 / Go 1.25 / 当前 unipdf v3 上。视为参考;不在我们提交套件里。
形状和 gofpdf 对比相同:在实际负载上 gpdf 比 unipdf 快 10〜80 倍。每张表格密集页 108 µs,单核每秒能产生约 9,000 张发票。重点不是吹嘘 — 是你可以不再考虑 PDF 生成是否要缓存或异步队列。在请求路径上生成对绝大多数场景都够用。
gpdf 没有的那些怎么办
如果你的 unipdf 账单是为 OCR、redaction 或 PDF 解析付的,这次迁移到不了终点。诚实的选项:
- OCR。gpdf 不做 OCR,短期内也不会。用 Tesseract 通过 gosseract 调用,或托管 OCR API。生成留在 gpdf,解析走别的路径。
- PDF 解析 / 文本抽取。gpdf 按设计只做生成。读取侧 pdfcpu (Apache 2.0) 能覆盖很多常见情况。把 unipdf 留给解析专用,可能减少席位数。
- AcroForm 字段创建。gpdf 能扁平化已有 AcroForm 字段,还不能创建新字段。如果你产出的是用户在阅读器里填的表单,这是你会感受到的 gap。已在 roadmap。
- Redaction (涂黑)。gpdf 路线图里没有。redaction 需要真正的渲染器知道要黑掉什么,是和生成不同的架构。
对 生成、加密、签名、字体、CJK 这条路径 — 大多数 unipdf 账单实际买的东西 — 替换是完整的。
FAQ
gpdf 是 unipdf 的 fork 吗? 不是。gpdf 是纯 Go 的全新实现。PDF 线格式、布局引擎、TrueType 子集器、AES、PKCS#7 — 全部从零写起。和 unipdf 无共享代码,无血缘,许可证清白上没有任何隐患因为没复制任何东西。
gpdf 真的是 MIT 吗?没有"在某些条件下变 AGPL"的条款? 是的。仓库的 LICENSE 是 MIT 原样,没有附录,没有使用领域条款,没有商用层切割。可以在闭源分发产品中使用、嵌入商用 SaaS、随设备分发。唯一义务是在你的发行物中包含许可证和版权声明。
传递依赖里有没有 copyleft 偷偷藏着?
gpdf 核心的 go.mod require 块是空的。没有传递的 AGPL,没有传递的 GPL,没有任何传递依赖。go get 后用 go mod graph | grep gpdf 验证。
移除许可证密钥真的那么重要? 对一些团队是全部。许可证密钥要进 secret manager,要轮换,要审计,要包含在每个容器镜像里,且不能泄露在日志中。多租户 SaaS 几百个 pod 里,这是真实的运维表面。删掉这个要求消除一类事故。
我现有 unipdf 代码到处用 creator.Block.SetPos 做绝对定位。gpdf 有等价物吗?
有 — page.Absolute(x, y, fn) 把子树放到明确坐标。但如果你的代码大量用绝对定位,布局引擎模型是思维转变,不是语法转换。估工时之前先读 12 栅格文章。重写后的代码通常比原来短。
如果将来 UniDoc 把 unipdf 改成 MIT? 那你多一个选项。gpdf 背后的押注不是 "unipdf 永远 AGPL";而是 "需要启动注册调用、按开发者收费的许可证,对大多数负载是不该存在的税"。即使明天 unipdf 改了,许可证密钥的运维表面还会留着直到他们删掉它。
试试 gpdf
gpdf 是 Go 的 PDF 生成库。MIT 许可,零外部依赖,无许可证密钥,原生 CJK 支持。
go get github.com/gpdf-dev/gpdf