用 Go 生成发票 PDF:50 行以下的完整代码
可运行的完整发票 PDF 生成器,50 行 Go 代码。零依赖,不需要 Chromium,不需要 CGO。gpdf 一个包搞定表头、表格、合计。
TL;DR
用 Go 写一个可运行的发票 PDF,端到端 50 行。一个 main.go,一次 go get,不用 Chromium,不用 CGO,没有模板语言,没有 HTML。带表格、条纹行、右对齐合计。代码在下方,其余内容讲每一块的作用和这种写法何时会失效。
想直接看代码:
go get github.com/gpdf-dev/gpdf
然后粘贴下一节的 main.go。
为什么"50 行以下"是我们关注的阈值
坦白讲,搜索"go generate invoice pdf"出来的结果大多是两种:(a) 推荐起一个无头 Chromium;(b) 展示 400 行低级 PDF 操作符来渲染一个表格。两者技术上都没错,但都不是这个任务应有的形状。
一张合理的发票需要:
- 发行方和客户信息的表头
- 发票号和到期日
- 明细行表格
- 合计金额
四件事。所以代码也应该是四块。超过一屏的话,是库选错了。
50 行 大致是普通编辑器一屏能放下的上限,也是审核者会从头看到尾而不是直接跳到测试的阈值。达到这个门槛,生成结果贴到 Slack 一条消息里,别人就能通过这条消息学会这个库。这是目标线。
下方代码经过 gofmt 格式化,import 完整展开,所有错误路径都处理。没有隐藏的辅助包,没有小技巧。看到什么就是什么能编译的内容。
50 行
package main
import (
"log"
"os"
"github.com/gpdf-dev/gpdf"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/pdf"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := gpdf.NewDocument(template.WithPageSize(document.A4))
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text("ACME 公司", template.FontSize(22), template.Bold())
c.Text("上海市浦东新区世纪大道 100 号")
})
r.Col(6, func(c *template.ColBuilder) {
c.Text("发票 #INV-2026-001", template.Bold(), template.AlignRight())
c.Text("到期日: 2026-03-31", template.AlignRight())
})
})
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Spacer(document.Mm(6))
c.Table(
[]string{"项目", "数量", "单价", "金额"},
[][]string{
{"前端开发", "40 小时", "¥1,080", "¥43,200"},
{"后端开发", "60 小时", "¥1,080", "¥64,800"},
{"UI/UX 设计", "20 小时", "¥900", "¥18,000"},
},
template.ColumnWidths(40, 15, 20, 25),
template.TableHeaderStyle(template.Bold(), template.BgColor(pdf.RGBHex(0xF0F0F0))),
template.TableStripe(pdf.RGBHex(0xFAFAFA)),
)
c.Text("合计: ¥126,000", template.AlignRight(), template.Bold(), template.FontSize(14))
})
})
b, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("invoice.pdf", b, 0644); err != nil {
log.Fatal(err)
}
}
注意: 上方代码中的中文字符在默认字体下会显示为豆腐块 (□)。中文字体嵌入见最后一节"中文支持"。先看结构。
go run . 在当前目录生成 invoice.pdf。M1 上整个程序几毫秒完成,真正的 PDF 生成不到 150 µs,其余是进程启动时间。
每块代码在做什么
import
gpdf 的 4 个包:
github.com/gpdf-dev/gpdf— 门面包。只用gpdf.NewDocument,内部是template.New的薄封装。github.com/gpdf-dev/gpdf/document— 单位 (Mm,Pt,Cm,In,Em,Pct)、纸张大小 (A4,Letter,Legal)、页边距。github.com/gpdf-dev/gpdf/pdf— 颜色原语 (RGBHex,Gray,pdf.White等常量)。github.com/gpdf-dev/gpdf/template— Builder API 本体。所有template.前缀的选项、布局函数、样式修饰符都在这里。
零外部依赖。执行 go get github.com/gpdf-dev/gpdf 后 go.mod 的 require 只有一行,没有 indirect 雪崩。
文档构建
doc := gpdf.NewDocument(template.WithPageSize(document.A4))
gpdf.NewDocument 接受 ...template.Option 变参。页面大小、页边距、默认字体、元数据、自定义字体全部是 WithXxx 选项。默认页边距 20 mm。
表头行
page.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) { ... })
r.Col(6, func(c *template.ColBuilder) { ... })
})
gpdf 采用 12 列网格,与 Bootstrap 同一心智模型。一行水平有 12 个单位,r.Col(6, ...) 占一半,两个 Col(6) 加起来正好填满。
AutoRow 表示行高由最高的列决定。列内用 c.Text(...) 从上到下堆叠,不用显式定位,builder 内部维护光标并按元素渲染高度推进。
明细表格
c.Table(
[]string{"项目", "数量", "单价", "金额"},
[][]string{ /* rows */ },
template.ColumnWidths(40, 15, 20, 25),
template.TableHeaderStyle(template.Bold(), template.BgColor(pdf.RGBHex(0xF0F0F0))),
template.TableStripe(pdf.RGBHex(0xFAFAFA)),
)
ColumnWidths 是百分比而不是绝对点值,四个数的和应为 100。和不等于 100 不会报错但最右列可能溢出——这是唯一的坑。
TableHeaderStyle 接受所有文本选项 (Bold, TextColor, BgColor, AlignCenter)。TableStripe(color) 渲染斑马行。
表格自动处理行高和分页。超出当前页时 gpdf 自动换页并在续页上重绘表头。
合计
c.Text("合计: ¥126,000", template.AlignRight(), template.Bold(), template.FontSize(14))
表格外的另一个 Text 调用,右对齐,稍大号字体。
生成与写入
b, err := doc.Generate()
if err != nil { log.Fatal(err) }
if err := os.WriteFile("invoice.pdf", b, 0644); err != nil { log.Fatal(err) }
doc.Generate() 返回 ([]byte, error),不触碰文件系统。字节切片本身就是完整 PDF,可以写盘、上传 S3、作为 HTTP 响应 w.Write(b) 或邮件附件。也可以用 doc.Render(w io.Writer) 流式写入。
中文支持
上述代码默认字体只有 Latin 字形,中文会显示为豆腐。用 Noto Sans SC 嵌入一个 TTF 即可:
ttf, err := os.ReadFile("NotoSansSC-Regular.ttf")
if err != nil { log.Fatal(err) }
doc := gpdf.NewDocument(
template.WithPageSize(document.A4),
template.WithFont("NotoSansSC", ttf),
template.WithDefaultFont("NotoSansSC", 10),
)
gpdf 自动做字形子集化,不会把整个 3 MB TTF 嵌入 PDF。只有实际用到的字形被嵌入。
繁体中文见 Go 处理中文 PDF — 简繁字体选型 (相似逻辑)。
不破坏 50 行前提下美化
品牌色。 挑一个 hex 值 (例如深蓝 0x1A237E) 贯穿公司名和表头:
brand := pdf.RGBHex(0x1A237E)
c.Text("ACME 公司", template.FontSize(22), template.Bold(), template.TextColor(brand))
template.TableHeaderStyle(template.Bold(), template.TextColor(pdf.White), template.BgColor(brand)),
小计和税额。 合计上方分行列出:
c.Text("小计: ¥126,000", template.AlignRight())
c.Text("增值税 (13%): ¥16,380", template.AlignRight())
c.Text("合计: ¥142,380", template.AlignRight(), template.Bold(), template.FontSize(14))
增值税发票要素。 开票方的统一社会信用代码、购方名称和纳税人识别号只是多几行 c.Text,布局不变。
运行
mkdir invoice-demo
cd invoice-demo
go mod init example.com/invoice-demo
go get github.com/gpdf-dev/gpdf
# 粘贴 main.go
go run .
这种写法什么时候会失效
- 明细变成数据。 从数据库或 JSON 读取时,只是构造
[][]string的过程变了,表格代码不变。 - 需要复用布局。 一旦要循环生成多张发票,把主体抽到
func renderInvoice(doc *template.Document, inv Invoice)。 - 布局有条件分支。 不同客户列不同时,Builder API 会变冗长,换成 JSON 模板入口 或 Go 模板入口 更合适。
- 需要 CJK 字符。 见上方中文支持节。
以上四种情况都不会要求"推倒重来",都是增量扩展。
FAQ
可以商用吗? 可以。gpdf 是 MIT 许可证,闭源商用产品也可嵌入,无署名要求。
可以不经过字节切片直接写到 io.Writer 吗?
可以。doc.Render(w io.Writer) error。
实际有多快? 上方 50 行在 M1 上约 100 µs 生成 PDF。单页 hello world 是 13 µs。批量场景下 gpdf 不会成为瓶颈。
有没有 gpdf.Invoice 辅助函数?
没有。发票格式在不同国家差异很大,任何简化都会漏掉某些场景。50 行的起点比一个构造器更灵活。
支持 PDF/A 吗?
支持 PDF/A-2b,构建时传 gpdf.WithPDFA(pdfa.Level2B)。详见 纯 Go 构建 PDF/A-2b。
试用 gpdf
gpdf 是 Go 的 PDF 生成库。MIT 许可证、零依赖、原生 CJK,基准测试中比其他库快 10–30 倍。
go get github.com/gpdf-dev/gpdf
下一步阅读
- 为什么 gpdf 比其他 Go PDF 库快 10–30 倍 — 上面"数百微秒"说法的基准数字。
- 2026 Go PDF 库横评 — 同样的发票用 gofpdf / gopdf / Maroto 写各自需要多少行。
- gpdf 的 12 列网格是怎么工作的 — 上方表头行使用的布局模型细节。