Go PDF 表格:列宽、斑马纹、分页头
Go 里画 PDF 表格容易翻车。gpdf 把列宽、斑马纹、跨页表头重复压缩到一次 Table 调用。完整 API 与权衡。
一句话总结
表格是 PDF 生成里最容易翻车的部分。 加起来不对的列宽、第二页消失的表头、被行循环 off-by-one 画错的斑马纹。gpdf 把这一切折叠成一次调用:
c.Table(header, rows,
template.ColumnWidths(40, 15, 20, 25),
template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
)
列宽、斑马纹、分页时表头自动重复 一次到位。不写行循环。没有 PageBreak 选项。当表格放不下时布局引擎会发现,并在下一页顶部重新发出 Header 切片。需要列合并、行合并、跨页重复的表尾时,下沉到 document.Table 一层 —— 同样的积木,更细粒度的控制。
本文讲表格设计真正重要的三个轴 (列宽、斑马纹、分页),gpdf 各自做了什么,以及抽象在哪里有意停下。
为什么写这篇文章
gpdf 是一个 Go 的 PDF 生成库。MIT、零依赖、单页渲染约 13 µs。表格部分的 API 很小 —— 只有 8 个 TableOption —— 但承受的设计压力很大。Go 的 PDF 项目几乎都是卡在表格上。
Go 里写表格容易翻车的三件事:
- 列宽。 Web 有 CSS
<col>和colgroup。PDF 什么都没有。要么自己用点(pt)算每一列,要么接受库给的等分。 - 斑马纹。 想让正文每隔一行变浅灰好读。低层库要自己写循环、跟踪
i % 2,这是表格 bug 的一半源头。 - 分页。 200 行的报表 A4 单页放不下。库需要 (a) 在合理位置切分,(b) 关闭当前页,(c) 打开新页,(d) 在新页顶部重画表头,让读者知道列名。少一项这个表格就废了。
这篇按顺序解释 gpdf 怎么解决每一个,以及做了什么取舍。只想要复制粘贴的食谱请看末尾的相关链接。这是给"我能不能把每月一万行的对账单交给这个 API"做判断用的长版。
API 形状
构建器层只有一个入口:
func (c *ColBuilder) Table(header []string, rows [][]string, opts ...TableOption)
表头是字符串切片,正文是字符串切片的切片,可变长 opts 配置其余一切。一共 8 个选项构造器:
| 选项 | 控制内容 |
|---|---|
ColumnWidths(...float64) | 父 Col 宽度的百分比,每列一个 |
TableHeaderStyle(...TextOption) | 表头背景色与文字颜色 |
TableStripe(pdf.Color) | 正文交替行的背景色 |
TableCellVAlign(document.VerticalAlign) | 正文单元格的垂直对齐 |
WithTableBorder(BorderSpec) | 整张表的外框 |
WithTableCellBorder(BorderSpec) | 每个单元格的相同边框 —— 网格效果 |
WithTableBorderCollapse(bool) | CSS border-collapse: collapse 语义 |
WithTableBackground(pdf.Color) | 整张表的背景填充 |
构建器层全部表面就是这些。能用构建器搭的都用这 8 个搭。这之外 —— 列合并、行合并、表尾、固定 pt 宽 —— 走 document.Table。后面会讲。
可运行代码:六个月发票台账
完整可运行示例。保存为 main.go,执行 go run .,得到 ledger.pdf。
package main
import (
"fmt"
"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(
gpdf.WithPageSize(gpdf.A4),
gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
)
brand := pdf.RGBHex(0x1A237E)
stripe := pdf.RGBHex(0xF5F5F5)
hairline := template.Border(
template.BorderWidth(document.Pt(0.5)),
template.BorderColor(pdf.Gray(0.85)),
)
header := []string{"日期", "发票号", "客户", "金额"}
rows := make([][]string, 0, 120)
for i := 1; i <= 120; i++ {
rows = append(rows, []string{
fmt.Sprintf("2026-%02d-%02d", (i%6)+1, (i%28)+1),
fmt.Sprintf("INV-%05d", 10000+i),
fmt.Sprintf("客户 #%d", i),
fmt.Sprintf("¥%d", (100+i*7)*10),
})
}
page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Text("2026 上半年台账", template.FontSize(18), template.Bold())
c.Spacer(document.Mm(4))
c.Table(header, rows,
template.ColumnWidths(20, 20, 40, 20),
template.TableHeaderStyle(
template.TextColor(pdf.White),
template.BgColor(brand),
),
template.TableStripe(stripe),
template.WithTableCellBorder(hairline),
)
})
})
data, err := doc.Generate()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("ledger.pdf", data, 0o644); err != nil {
log.Fatal(err)
}
}
A4 上 120 行大约会跨 5 页。每页顶部会重画深蓝表头,正文从上一页结束处继续,斑马纹的交替模式跨页保持一致。这些都不需要额外写代码。
这段代码值得看的是 缺少的东西:没有行循环、没有页计数器、没有 if i == lastRowOnPage、没有 PageBreak() 调用、没有手动重画表头。四行选项声明表格长什么样,引擎负责"在哪里分页"。
列宽:百分比到底是什么的百分比
ColumnWidths(40, 15, 20, 25) 看起来像 CSS 的 <col width="40%">。差不多,但有三个尖锐的边角值得知道。
百分比是父 Col 的,不是页面的。 r.Col(6, ...) 占一行内容宽度的一半。其中一张 ColumnWidths(50, 50) 的表,每列占行宽的 25%,不是页宽的 50%。百分比对表格所在位置是局部的。把表格从整宽行换到并排布局时,选项调用本身不需要改。
不归一化。 加起来等于 90,右边就空 10%。等于 110,最右列就溢出父级流到页外。gpdf 信任你的算术。不会警告 —— 也不应该 —— 自动改你写的值比那个 bug 危害更大。
尾部缺省自动分配。 给的宽度数量少于列数时,剩余列等分剩余空间:
// 五列表格,给三个宽度
template.ColumnWidths(40, 10, 20)
// → 40% / 10% / 20% / 15% / 15% (剩余 30% 二等分)
"我只关心这几列宽度,剩下的随便"是个有用的招法。显式传 0 也能让某列变成自动:
template.ColumnWidths(0, 30, 30) // 三列表格 → 40% / 30% / 30%
列宽的边角细节请看 列宽食谱。一句话:百分比覆盖 95% 的布局,剩下 5% 下沉一层。后面讲。
斑马纹:你不用写的行循环
template.TableStripe(pdf.RGBHex(0xF5F5F5))
就这一行。gpdf 用从 0 开始的索引 i 遍历正文行,给 i % 2 == 1 的行上色。表头是单独切片不计数,所以第一行正文是干净的、第二行是阴影 —— Bootstrap 约定。
为什么需要这个选项:在 gofpdf 和 gopdf 里你要自己写循环、每行 SetFillColor、给 CellFormat 传填充标志。八到十行代码,off-by-one 的发生率高到 StackOverflow 上专门有一组答案。把它压缩到一个选项里,整个 bug 类别就消失了。
约束是有意的:
- 只支持一个斑马色,不是两色交替。 不能"蓝灰交替"。页面本身就是白的,所以没上色的行自动是白色。再加第三色循环只会增加阅读负担 —— 斑马纹是为减负而存在的。
- 不能反转奇偶。 第一正文行始终干净,第二行始终阴影。真要反转就在数据前面塞个空行。但其实没人想反转。
- 斑马纹跨页保持正确。 正文第 14 行流到第二页时还是 14,奇偶性不变。引擎跨分页携带索引。
颜色选择和暗色主题变体见 斑马纹食谱。本篇要点:表格的 属性(交替模式)在表格调用处声明,不是在行级别配置。
分页:真正难的部分
大多数 Go PDF 故事都在这里崩盘,也是 gpdf 设计回报最高的地方。
简单版:给表格塞超过单页容量的行,gpdf 自动分页。Header 切片在每个续页顶部重新绘制。 没有要打开的选项,没有要调用的方法。是布局引擎的默认行为。
详细版更有意思。块布局引擎 (document/layout/block.go) 用可用高度布局表格。正文放不下时返回结果会带 Overflow 字段 —— 同样的 Header、同样的 Footer、剩余的正文行 组成的新 *document.Table。页面系统把已布局部分写出到当前页,开新页,再用新页的可用高度把溢出表格喂回布局引擎。直到溢出为空。
两个推论:
- 表头住在
tbl.Header里,不在循环里。 因为溢出表格复用同一个Header切片,续页顶部自动重复表头。样式、列宽、所有都一样。 - 不存在"表头放不下"的边界情况。 布局引擎在测量正文行之前先为表头预留空间。如果一页连表头加至少一行正文都装不下,整张表被推到下一页。
表尾 —— 在 document 层用时 —— 工作方式相同。每个续页底部自动重绘。
没有的功能:「这组行别拆开」标注、特定行不分页、「这张表从新页开始」指令。前两个是 TODO。第三个在页层面做 —— 在包含表格的行之前调 doc.AddPage()。
走出构建器 API
构建器对常见情况好用。需要单元格合并、固定 pt 宽、每页重复的表尾,或单元格混合多种内容类型时,下沉到 document.Table。
import (
"github.com/gpdf-dev/gpdf/document"
)
footer := document.TableRow{
Cells: []document.TableCell{
{
Content: []document.DocumentNode{
&document.Text{Content: "合计", TextStyle: document.DefaultStyle()},
},
ColSpan: 3, // ← 跨前三列
RowSpan: 1,
},
{
Content: []document.DocumentNode{
&document.Text{Content: "¥48,720", TextStyle: document.DefaultStyle()},
},
ColSpan: 1,
RowSpan: 1,
},
},
}
tbl := &document.Table{
Columns: []document.TableColumn{
{Width: document.Pct(20)},
{Width: document.Pct(20)},
{Width: document.Auto},
{Width: document.Pt(80)}, // 不随页宽变化的固定 80pt
},
Header: /* ... */,
Body: /* ... */,
Footer: []document.TableRow{footer},
}
几点。TableColumn.Width 是 document.Value 类型,可取 Pt / Mm / Cm / In / Em / Pct,以及特殊的 Auto。一张表里可以混用。Auto 列共享固定列与百分比列扣除后的剩余。比构建器的纯百分比模型更接近 CSS <col> 元素。
TableCell.ColSpan 和 RowSpan 是整数,默认 1。例子里前三列合并写"合计",第四列放金额 —— 经典发票表尾。
document.Table.Footer 是 []TableRow,和表头一样每页重复。构建器 API 不暴露是因为短表格大多用不到 —— 真要用时已经离开"常见情况"区域了。
这是 gpdf 的整体模式:高层构建器优雅覆盖 90%,document 层在旁边等着另外 10%。不是两个库。同一份文档里可以混用构建器行和手搭行。构建器只是同一个 document.Table 节点的构造函数。
边框与盒模型
三个边框选项,三个不同任务:
template.WithTableBorder(spec) // 整张表外框
template.WithTableCellBorder(spec) // 每个单元格相同边框
template.WithTableBorderCollapse(true) // 合并相邻单元格边框
默认无边框。要外框加 WithTableBorder。要网格加 WithTableCellBorder。两者都加得到"框 + 网格"。BorderSpec 用 template.Border(template.BorderWidth(...), template.BorderColor(...)) 构造。
WithTableBorderCollapse(true) 是 CSS 同名属性的语义:相邻单元格边框合并为一条线(不重复绘制)。发丝级网格中这个更干净;故意要双线的粗边框就关掉。默认分离。
实用组合:发丝级单元格边框 + 浅斑马纹:
c.Table(header, rows,
template.ColumnWidths(40, 20, 15, 25),
template.TableHeaderStyle(template.TextColor(pdf.White), template.BgColor(brand)),
template.TableStripe(pdf.RGBHex(0xF5F5F5)),
template.WithTableCellBorder(template.Border(
template.BorderWidth(document.Pt(0.5)),
template.BorderColor(pdf.Gray(0.85)),
)),
template.WithTableBorderCollapse(true),
)
会计师 Excel 打印预览的那种感觉。发票、对账单、台账、报销单 —— 财务相邻文档的合理默认。
与替代方案对比
参考一下,同样的"多页 + 斑马纹"表格在常被 gpdf 替换的库里怎么写:
| 库 | 表格代码行数 | 分页表头重复 | 斑马纹 | 备注 |
|---|---|---|---|---|
| gpdf | 约 10 行 | 自动 | TableStripe(...) | 构建器与底层都可用 |
| jung-kurt/gofpdf (2021 归档) | 40〜60 行 | 手动:跟踪 Y、调 AddPage、重绘表头 | 行循环里 SetFillColor | 奠基者,已停止维护 |
| go-pdf/fpdf (2025 归档) | 40〜60 行 | 同上 | 同上 | gofpdf 分支,模型相同 |
| signintech/gopdf | 50〜80 行 | 手动 | 手动 | 更底层 |
| johnfercher/maroto v2 | 约 15 行 | 自动 | 行级 WithBackgroundColor 手动 | 基于 gofpdf;API 漂亮但带依赖 |
| unidoc/unipdf | 约 12 行 | 自动 | 行样式辅助函数 | 商业许可 |
构建器行数比起来差距没那么大。真正的差异在用了 6 个月之后才显现。需求漂移时 —— 多了一列要不同对齐、报表要日文版、客户要在表尾打印行数 —— 用 gofpdf 或 gopdf 每次都要碰行循环。用 gpdf 选项列表变长,正文不动。
µs 级基准见 gpdf 为何更快。更广维度的对比见 2026 库总览。
表格里的 CJK
上面对比表里看不到的事实:gpdf 原生渲染 CJK 字形。没有"表格的中文模式" —— 注册一次字体后表格就用它。
ttf, _ := os.ReadFile("NotoSansSC-Regular.ttf")
doc := gpdf.NewDocument(
gpdf.WithPageSize(gpdf.A4),
gpdf.WithFont("NotoSansSC", ttf),
gpdf.WithDefaultFont("NotoSansSC"),
)
c.Table(
[]string{"日期", "发票号", "客户", "金额"},
[][]string{
{"2026-04-01", "INV-10001", "示例科技有限公司", "¥120,000"},
{"2026-04-02", "INV-10002", "山田商店", "¥38,500"},
},
template.ColumnWidths(20, 20, 40, 20),
)
表头中文、正文中文、列宽仍然是百分比、跨页表头重复仍然生效。字体只对文档使用的字形做子集化,单页输出只 50 KB 左右,而完整 Noto Sans SC 是 6 MB。
字体登记本身见 嵌入中文 TrueType 字体 食谱(同模式适用简繁中文)。本篇要点:数据是 CJK 时表格 API 不变。
常见问答
Q: 支持每行单独样式吗?
构建器 API 不行。构建器对正文取 [][]string,所有正文单元格共享列派生的同一个 Style。要单独行风格请去 document.Table 层组装 —— 每个 TableCell 携带自己的 CellStyle。模式直接,只是失去 [][]string 的便利。
Q: 单元格里能放图片或嵌套表格吗?
document.Table 层可以。TableCell.Content 是 []DocumentNode,能放 *Text、*Image,甚至嵌套 *Table。构建器的字符串 API 不暴露,因为这是大多数用户不想要的尖锐边缘,但底层模型支持。
Q: gpdf 怎么决定分页位置?
逐行。布局引擎按顺序测每行,加到当前页直到下一行会超出可用高度。那一行成为溢出表格的首行。还没有"这些行不要分开"标注 —— 每行都可分。要让发票行项目的逻辑组保持单页,要么手动在组前换页,要么去 document 层插入分页提示。
Q: gpdf 能渲染的最大表格?
A4 上 1 万正文行已验证。正确分页,每页表头重绘,输出 PDF 约 150 页几百 KB。瓶颈不是表格布局,是单元格内容的文字塑形,复杂度 O(行 × 列)。要 10 万+ 行就分块写盘(每 1 万行一次 Generate),或在 document.Table 层喂预塑形 run。
Q: 表尾能只在最后一页出现吗?
内置不支持。document.Table.Footer 设计上每页重复 —— 常见用途是分页列合计。要文档末尾出一次的汇总,作为表格 之后 的独立行块追加,不要放表格里。
Q: WithTableCellBorder 影响表头吗?
影响。单元格边框对表头和正文一致。要表头边框不同(比如表头底边更粗),在 document 层组表头并对单元格设 CellStyle.Border。
设计大局
只带走一件事:gpdf 表格 API 小,是因为表格问题几乎全归结为同样的三个问题。 列宽、斑马纹、分页。其余是长尾。把常见情况放构建器、长尾放 document 层是这笔交易 —— 日常每天的用途五行写完,需要构建器表达不了的事时不用付抽象代价。
代价坦白:没有 setRowStyle(i, ...) 捷径,也不会有。要把第 4 行和第 5 行做成不同样式,你已经越过构建器不打算处理的复杂度线。下沉一层。边界清晰且稳定。
全文到此。一次读完,之后这块 API 不用再多想。
试用 gpdf
gpdf 是 Go 的 PDF 生成库。MIT、零依赖、原生 CJK。
go get github.com/gpdf-dev/gpdf
相关阅读
- 如何在 gpdf 表格中设置列宽? —
ColumnWidths边角细节 - 如何创建斑马纹(隔行变色)表格? — 颜色选择与暗色主题
- PDF 的 Bootstrap 思维:gpdf 的 12 列网格 — 表格百分比对应的父 Col
- Go 用不到 50 行生成发票 PDF — 表格在完整文档里的真实用法
- gpdf 为何比 gofpdf / gopdf / Maroto 快 — 对比表背后的 µs 数字