全部文章

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,要么报错要么每页输出加水印。

你大致会处于以下三种模式之一:

  1. AGPL 模式。你以 AGPL v3 公开自己的代码。所有接触 unipdf 的字节,加上链接到它的所有代码,必须能让通过网络访问该服务的任何人获取。对内部工具和 SaaS 产品来说,这基本不可行。
  2. 商用模式。你按每开发者每年付费给 UniDoc。价格变化,最近公开报价大约每席每年四位数美元,包含一个 metering 或许可证密钥注册调用,每个二进制启动时都要执行。密钥被当成 secret 处理,意味着它要进 secret manager 并注入每个容器。
  3. 试用 / 评估模式。限时免费。输出带水印。生产环境用不了。

这三种模式本身都不是错的。UniDoc 是真公司、真工程师维护的产品,价格反映真实成本。问题在于这个许可证决策渗透到每一层:法务审查、密钥轮换、财务续约、部署面 (每个容器都要密钥)。gpdf 因为是 MIT,把这一整列从你的电子表格里抹掉了。

你失去什么,保留什么

进入 API 之前先实事求是。unipdf 有些事 gpdf 做不到:

能力unipdfgpdf
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 = 12template.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)
输出到 writerc.Write(w)doc.Render(w)

两个结构性转变要记住。unipdf 的 creator 是 有状态 的:你构建一个 ParagraphTable,然后调用 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))
        })
    })
}

PageNumberTotalPages 是占位符,布局引擎在分页之后解析它们。页眉和页脚本身就是树,不是你手动定位的 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 收集的。

基准gpdfunipdf*gofpdfMaroto v2
单页13 µs~180 µs132 µs237 µs
4×10 发票表格108 µs~8.6 ms241 µs8.6 ms
100 页报告683 µs~95 ms11.7 ms19.8 ms
复杂 CJK 发票133 µs~12 ms254 µs10.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

⭐ Star on GitHub · 文档

接下来读什么