用 Go 生成会计报表 PDF:日记账、总账、试算平衡表
用 Go 生成会计报表 PDF:从同一份分录切片输出日记账、总账和试算平衡表。附完整 gpdf 代码,含带框线的表格、金额右对齐与自动分页。
TL;DR
只要一份分录切片,同一个 Go 程序就能输出日记账、总账和试算平衡表。用 gpdf v1.0.17,整个 main.go 是 240 行。其中包括带框线的表格、右对齐的金额、跨页时重复的表头行,以及 1 / 3 形式的页码。不需要 Chromium,不需要 CGO,也不需要报表工具的许可证。
一个月的日记账 (67 笔分录) 是 2 页,在 Apple M1 上耗时约 9 ms。10,000 笔分录时是 170 页,耗时约 0.7 秒。
报表是汇总,不是表格
有人问怎么用 Go 输出会计报表时,第一个问题通常是怎么画格线。工作量并不在那里。
日记账、总账和试算平衡表看起来是三种不同的文档,但它们来自同一份数据。日记账按日期顺序列出分录。总账拿同样的分录按科目重新分组,再加上逐笔余额。试算平衡表把每个科目折叠成一行。三者的区别只是 GROUP BY 不同。
所以设计是一种数据类型、三个汇总函数、一个渲染函数。渲染函数就是 gpdf 的 c.Table,它很薄。正因如此,本文大部分篇幅花在汇总这一侧。
本文写给用 Go 开发会计、计费或费用报销系统、并在需求里看到"报表输出"几个字的人。
示例采用日本的簿记:报表名称是 仕訳帳 (日记账)、総勘定元帳 (总账) 和 合計残高試算表 (试算平衡表),科目名称也是日文。结构就是各地通用的复式记账。保留日文文本也说明了一点:CJK 输出除了注册字体之外不需要任何特殊处理。
数据模型:只有一种分录
type Entry struct {
Date, Debit, Credit, Memo string
Amount int64
}
一笔分录就是一个借方科目、一个贷方科目和一个金额。金额是以整数日元表示的 int64。如果用 float64,试算平衡表差 1 的那天,你会花一整晚去找原因。在 0.1 + 0.2 不等于 0.3 的世界里,借贷相等的检查是做不成的。
科目用两张表来描述。
var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}
var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}
accounts 是显示顺序:现金、银行存款、应收账款、应付账款、资本金、销售收入、采购、租金。总账和试算平衡表都按这个顺序排列。debitSide 标记在借方增加的科目,也就是资产和费用。负债、所有者权益和收入在贷方增加。总账计算余额列时需要这个方向。
这是一种简化。真实的分录并不总是一借一贷。一笔工资分录有一行借方和三行贷方:银行存款、代扣所得税和代扣社会保险。要支持复合分录,把 Entry 改成凭证号加一个明细行切片即可。汇总函数改为遍历明细行,渲染代码完全不用动。
完整代码
把 NotoSansJP-Regular.ttf 放到执行过 go mod init 的目录里,并把下面的代码保存为 main.go。已在 gpdf v1.0.17 上测试。
go get github.com/gpdf-dev/gpdf
package main
import (
"fmt"
"log"
"os"
"sort"
"strconv"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/pdf"
"github.com/gpdf-dev/gpdf/template"
)
// Entry 是一笔分录。金额用 int64 表示整数日元,绝不用 float64。
type Entry struct {
Date, Debit, Credit, Memo string
Amount int64
}
// 按显示顺序排列的科目表:现金、银行存款、应收账款、
// 应付账款、资本金、销售收入、采购、租金费用。
var accounts = []string{"現金", "普通預金", "売掛金", "買掛金", "資本金", "売上高", "仕入高", "支払家賃"}
// 正常余额在借方的科目 (资产和费用)。其余科目的正常余额在贷方。
var debitSide = map[string]bool{"現金": true, "普通預金": true, "売掛金": true, "仕入高": true, "支払家賃": true}
func yen(n int64) string {
if n == 0 {
return ""
}
s := strconv.FormatInt(n, 10)
for i := len(s) - 3; i > 0; i -= 3 {
s = s[:i] + "," + s[i:]
}
return s
}
func newDoc(font []byte, title string) *template.Document {
doc := template.New(
template.WithPageSize(document.A4),
template.WithMargins(document.UniformEdges(document.Mm(15))),
template.WithFont("NotoSansJP", font),
template.WithDefaultFont("NotoSansJP", 9),
)
doc.Header(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(6, func(c *template.ColBuilder) {
c.Text(title, template.FontSize(16))
})
r.Col(6, func(c *template.ColBuilder) {
c.Text("株式会社サンプル商事", template.AlignRight())
c.Text("自 2026年4月1日 至 2026年4月30日", template.AlignRight())
})
})
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) { c.Spacer(document.Mm(4)) })
})
})
doc.Footer(func(p *template.PageBuilder) {
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder,
template.AlignCenter(), template.FontSize(8), template.TextColor(pdf.Gray(0.4)))
})
})
})
return doc
}
func table(c *template.ColBuilder, header []string, rows [][]string, widths []float64, aligns []document.TextAlign) {
// 截至 v1.0.17,c.Table 没有单元格内边距选项,文字会贴着
// 单元格边框。在对齐的一侧加一个空格,让文字离开框线。
pad := func(row []string) []string {
out := make([]string, len(row))
for i, s := range row {
switch {
case s == "":
case aligns[i] == document.AlignRight:
out[i] = s + " "
case aligns[i] == document.AlignLeft:
out[i] = " " + s
default:
out[i] = s
}
}
return out
}
header = pad(header)
padded := make([][]string, len(rows))
for i, row := range rows {
padded[i] = pad(row)
}
grid := template.Border(template.BorderWidth(document.Pt(0.5)), template.BorderColor(pdf.Gray(0.3)))
c.Table(header, padded,
template.ColumnWidths(widths...),
template.ColumnAlign(aligns...),
template.TableHeaderStyle(template.BgColor(pdf.RGBHex(0xEEEEEE))),
template.WithTableCellBorder(grid),
template.WithTableBorderCollapse(true),
)
}
func save(doc *template.Document, name string) {
b, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile(name, b, 0o644); err != nil {
log.Fatal(err)
}
}
const L, R = document.AlignLeft, document.AlignRight
// 日记账 (仕訳帳):每笔分录一行,按日期顺序。
func journal(font []byte, entries []Entry) {
var total int64
rows := make([][]string, 0, len(entries)+1)
for _, e := range entries {
rows = append(rows, []string{e.Date, e.Debit, yen(e.Amount), e.Credit, yen(e.Amount), e.Memo})
total += e.Amount
}
rows = append(rows, []string{"", "合計", yen(total), "", yen(total), ""})
doc := newDoc(font, "仕訳帳")
doc.AddPage().AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})
})
})
save(doc, "journal.pdf")
}
// 总账 (総勘定元帳):把每笔分录过账到对应科目,并保持逐笔余额。
func ledger(font []byte, entries []Entry) {
doc := newDoc(font, "総勘定元帳")
page := doc.AddPage()
for _, acct := range accounts {
var bal int64
var rows [][]string
for _, e := range entries {
var dr, cr int64
var counter string
switch acct {
case e.Debit:
dr, counter = e.Amount, e.Credit
case e.Credit:
cr, counter = e.Amount, e.Debit
default:
continue
}
if debitSide[acct] {
bal += dr - cr
} else {
bal += cr - dr
}
rows = append(rows, []string{e.Date, counter, e.Memo, yen(dr), yen(cr), yen(bal)})
}
if len(rows) == 0 {
continue
}
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text(acct, template.FontSize(11))
c.Spacer(document.Mm(1))
table(c, []string{"日付", "相手科目", "摘要", "借方", "貸方", "残高"}, rows,
[]float64{10, 14, 34, 14, 14, 14}, []document.TextAlign{L, L, L, R, R, R})
c.Spacer(document.Mm(5))
})
})
}
save(doc, "ledger.pdf")
}
// 试算平衡表 (合計残高試算表):每个科目的借方合计、贷方合计,以及净余额。
func trialBalance(font []byte, entries []Entry) {
dr, cr := map[string]int64{}, map[string]int64{}
for _, e := range entries {
dr[e.Debit] += e.Amount
cr[e.Credit] += e.Amount
}
var sumDr, sumCr, balDr, balCr int64
var rows [][]string
for _, a := range accounts {
var bd, bc int64
if d := dr[a] - cr[a]; d > 0 {
bd = d
} else {
bc = -d
}
rows = append(rows, []string{yen(bd), yen(dr[a]), a, yen(cr[a]), yen(bc)})
sumDr, sumCr, balDr, balCr = sumDr+dr[a], sumCr+cr[a], balDr+bd, balCr+bc
}
if sumDr != sumCr || balDr != balCr {
log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
}
rows = append(rows, []string{yen(balDr), yen(sumDr), "合計", yen(sumCr), yen(balCr)})
doc := newDoc(font, "合計残高試算表")
doc.AddPage().AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
table(c, []string{"借方残高", "借方合計", "勘定科目", "貸方合計", "貸方残高"}, rows,
[]float64{19, 19, 24, 19, 19}, []document.TextAlign{R, R, document.AlignCenter, R, R})
})
})
save(doc, "trial_balance.pdf")
}
func main() {
font, err := os.ReadFile("NotoSansJP-Regular.ttf")
if err != nil {
log.Fatal(err)
}
entries := []Entry{
{"04/01", "普通預金", "資本金", "設立出資", 3000000},
{"04/01", "現金", "普通預金", "小口現金引出", 100000},
}
// 生产环境里这是一次数据库查询。演示时生成一个月的分录。
for d := 2; d <= 30; d++ {
day := fmt.Sprintf("04/%02d", d)
entries = append(entries,
Entry{day, "仕入高", "買掛金", fmt.Sprintf("商品仕入 伝票 P-%03d", d), int64(40000 + d*1300)},
Entry{day, "売掛金", "売上高", fmt.Sprintf("商品売上 伝票 S-%03d", d), int64(88000 + d*2100)},
)
if d%10 == 0 {
entries = append(entries,
Entry{day, "普通預金", "売掛金", "売掛金回収", 600000},
Entry{day, "買掛金", "普通預金", "買掛金支払", 300000},
)
}
}
entries = append(entries, Entry{"04/30", "支払家賃", "普通預金", "5月分家賃", 180000})
sort.SliceStable(entries, func(i, j int) bool { return entries[i].Date < entries[j].Date })
journal(font, entries)
ledger(font, entries)
trialBalance(font, entries)
}
go run . 会写出 journal.pdf、ledger.pdf 和 trial_balance.pdf。下面是总账的第一页。

日记账:设置列宽和对齐
日记账是三者中最简单的。每笔分录变成一行,最后加一行合计。
table(c, []string{"日付", "借方科目", "借方金額", "貸方科目", "貸方金額", "摘要"}, rows,
[]float64{10, 14, 14, 14, 14, 34}, []document.TextAlign{L, L, R, L, R, L})
这里指定了两件事。template.ColumnWidths 接收百分比形式的列宽,加起来是 100。template.ColumnAlign 为每一列接收一个对齐方式:金额右对齐,其余左对齐。ColumnAlign 对表头行同样生效,所以列标题会右对齐地落在数字上方。
千位分隔符由 yen 函数在值进入表格之前加上。gpdf 没有数字格式化功能。c.Table 接收的是 [][]string,每个单元格怎么格式化由调用方负责。零会变成空字符串,因为在总账里,有借方金额的行,贷方单元格应该留空。印满 0 的报表很难读。
合计行在借方和贷方下面放的是同一个 total。分录一借一贷时,这按定义就成立。如果扩展到复合分录,要把两边分开求和。
总账:过账与逐笔余额
总账就是把日记账过账到各个科目。如果一笔分录借记应收账款、贷记销售收入,它会在应收账款分类账的借方列出现一次,在销售收入分类账的贷方列出现一次。这就是 67 笔分录变成 134 行总账的原因。
代码对每个科目扫描所有分录。
switch acct {
case e.Debit:
dr, counter = e.Amount, e.Credit
case e.Credit:
cr, counter = e.Amount, e.Debit
default:
continue
}
如果该科目在借方,金额进入借方列,贷方科目记为对方科目。如果在贷方,则反过来。否则跳过这笔分录。
这是一个科目 × 分录的循环,8 × 67 根本不算什么。到了 300 个科目、100,000 笔分录时,先把分录分桶放进 map[string][]Entry。到那个规模,汇总多半本来就在 SQL 里做了,Go 拿到的是已经按科目分好组的行。
余额列用 debitSide 决定方向。
if debitSide[acct] {
bal += dr - cr
} else {
bal += cr - dr
}
银行存款是借方余额科目,所以存入 (借方) 时增加,支付 (贷方) 时减少。应付账款是贷方余额科目,所以正好相反。漏掉这个分支,你会得到一份余额越来越负的应付账款分类账。数字在算术上是对的,财务团队照样会退回来,这让它成为比较难解释的 bug 之一。
顺便一提,借方在左、贷方在右并没有逻辑上的理由。这是复式记账在 15 世纪的意大利成形以来一直沿用的惯例,只能死记。这两个词本身也帮不上忙:"debit"和"credit"描述的是对方的立场,和大多数初学者的预期正好相反。把变量命名为 dr 和 cr,至少不会让代码再添一层混乱。
在布局这一侧,每个科目添加一个 page.AutoRow。
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text(acct, template.FontSize(11))
c.Spacer(document.Mm(1))
table(c, /* ... */)
c.Spacer(document.Mm(5))
})
})
科目名称、表格、间距,叠八次。这段代码里没有任何地方计算一页在哪里结束。
试算平衡表:渲染之前先失败
试算平衡表按科目显示借方合计和贷方合计,并把差额放在它所落的那一侧。按惯例科目名称在中间,借方在左、贷方在右,所以各列依次是借方余额、借方合计、科目、贷方合计、贷方余额。
这张报表最重要的几行在画表格之前。
if sumDr != sumCr || balDr != balCr {
log.Fatalf("out of balance: totals %d/%d balances %d/%d", sumDr, sumCr, balDr, balCr)
}
试算平衡表存在的意义就是发现过账错误。如果借方合计和贷方合计不一致,说明某处有一笔分录坏了。把一份不平衡的 PDF 排得漂漂亮亮没有意义。应该直接停下。
在示例数据中,合计是 11,269,600 日元,余额是 7,389,600 日元,两边一致。只有一借一贷的分录时,它们必然一致。一旦数据来自数据库,这三行就开始起作用了。在生产环境里,应返回 error 而不是调用 log.Fatalf,这样才能查出是哪个科目不对。
分页:什么都不用做
上面的代码没有任何分页逻辑。它不数行数,也不看剩余高度。
当表格在剩余空间里放不下时,gpdf 的布局引擎会拆分表体,并在下一页顶部重新绘制表头行。日记账的第 2 页以表头行开始,总账里应收账款表格的续页也是如此。doc.Header 里的报表标题和会计期间、doc.Footer 里的页码会出现在每一页上。机制的说明见如何让表格跨多页输出?。
页码是普通字符串里的两个占位符。
c.Text(document.PageNumberPlaceholder+" / "+document.TotalPagesPlaceholder, ...)
总页数在分页完成之后才填入。参见如何在 gpdf 页脚显示页码「第 X 页 共 Y 页」?。
实测数据
以下数字只针对日记账,使用按不同数量生成的销售分录。Apple M1,Go 1.25.2,gpdf v1.0.17。计时范围是从字体字节已在内存中,到 PDF 写入磁盘为止。
| 分录数 | 页数 | 耗时 | 文件大小 |
|---|---|---|---|
| 68 | 2 | 8.8 ms | 146 KB |
| 1,000 | 17 | 68 ms | 1.9 MB |
| 10,000 | 170 | ~0.7 s | 19 MB |
68 笔分录 8.8 ms,和 gpdf 单页 13 µs 的基准数字相比显得很慢。这段时间大部分花在字体上:Noto Sans JP 是一个 9.6 MB 的 TTF,每次运行都要解析并做子集化。除此之外,每多一行的开销约为 70 µs,增长是线性的。对于每晚为整个公司生成月度报表的批处理来说,数据库查询会先于 PDF 生成成为瓶颈。
文件大小需要直说。每页约 115 KB。gpdf v1.0.17 会压缩字体流和图片流,但不压缩页面的绘图操作符,而每个单元格都带边框的报表有大量操作符。10,000 行 19 MB,作为邮件附件太大了。按月拆分,或者去掉格线改用 template.TableStripe 斑马纹,是比较实际的绕过办法。
我们放弃了什么
单元格内边距。 v1.0.17 的 c.Table 没有设置单元格内边距的选项。加上单元格边框后,文字会贴着线。示例里的 table 函数在每个单元格的对齐一侧加了一个空格。不好看,但能用。用斑马纹代替边框的话,这个问题不会出现。
页小计与过次页行。 纸质账簿传统上在每页末尾写一行"过次页",在下一页开头写一行"承前页"。要做到这一点,汇总代码必须知道哪些行落在哪一页。gpdf 在布局阶段才决定分页,所以 builder API 没法告诉你。你可以固定每页的行数并自己分页,但那意味着放弃自动分页。我们还没有找到好的做法。
法律要求。 这段代码生成的是看起来像账簿的 PDF。它们是否满足你所在法域的记账保存规定,是另一个问题。以日本为例,在会计系统中创建的账簿一般依据《电子账簿保存法》以数据形式保存,PDF 只是用于查看或提交的输出。中国的电子会计档案规定同样是需要另行确认的问题。请查阅国税厅的指引 (日文) 或咨询你的会计师。
FAQ
用 Go 输出会计 PDF 需要报表工具吗?
对于日记账、总账、试算平衡表这类以表格为主的报表,不需要。把 [][]string 传给 gpdf 的 c.Table,就能得到边框、列宽、右对齐、自动分页和重复的表头行。gpdf 采用 MIT 许可证,零外部依赖,所以也没有运行时许可证。
可以用横向页面吗?
可以。把选项改成 template.WithPageSize(document.A4.Landscape())。摘要较长的日记账用横向更好读。列宽是百分比,所以代码的其他部分不用改。
日文以外的文字能用吗?
能。替换字符串,并用 template.WithFont 注册一个覆盖你所用文字的字体。对于日文,Noto Sans JP、IPAex Gothic 和 Source Han Sans 的用法都一样。参见如何在 gpdf 中嵌入日文字体?。
负数金额可以用红字或括号显示吗?
括号或负号属于字符串格式化的问题,在 yen 里处理即可。c.Table 没有按单元格设置文字颜色的选项。要显示红字,请按 Go PDF 表格:列宽、斑马纹、分页头里说明的方式直接构造 document.Table。
可以输出用于长期归档的 PDF/A 吗?
可以。向 gpdf.NewDocument 传入 gpdf.WithPDFA()。PDF/A 要求嵌入字体,而 CJK 报表本来就嵌入了。
上手 gpdf
gpdf 是一个 Go 的 PDF 生成库。MIT 许可证,零外部依赖,原生支持 CJK。
go get github.com/gpdf-dev/gpdf
延伸阅读
- Go PDF 表格:列宽、斑马纹、分页头 —
c.Table的全部选项,以及什么时候该下沉到document.Table。 - 用 Go 生成发票 PDF:50 行以下的完整代码 — 报表之前的那份单页文档。
- 用 Go 生成日文 PDF 的 2026 权威指南 — 从字体选择到修复豆腐块。
如果你想出了实现过次页行的干净做法,请在 GitHub 上提 issue。