全部文章

用 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/gpdfgo.modrequire 只有一行,没有 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

⭐ 在 GitHub Star · 阅读文档

下一步阅读