全部文章

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 里写表格容易翻车的三件事:

  1. 列宽。 Web 有 CSS <col>colgroup。PDF 什么都没有。要么自己用点(pt)算每一列,要么接受库给的等分。
  2. 斑马纹。 想让正文每隔一行变浅灰好读。低层库要自己写循环、跟踪 i % 2,这是表格 bug 的一半源头。
  3. 分页。 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 约定。

为什么需要这个选项:在 gofpdfgopdf 里你要自己写循环、每行 SetFillColor、给 CellFormat 传填充标志。八到十行代码,off-by-one 的发生率高到 StackOverflow 上专门有一组答案。把它压缩到一个选项里,整个 bug 类别就消失了。

约束是有意的:

  • 只支持一个斑马色,不是两色交替。 不能"蓝灰交替"。页面本身就是白的,所以没上色的行自动是白色。再加第三色循环只会增加阅读负担 —— 斑马纹是为减负而存在的。
  • 不能反转奇偶。 第一正文行始终干净,第二行始终阴影。真要反转就在数据前面塞个空行。但其实没人想反转。
  • 斑马纹跨页保持正确。 正文第 14 行流到第二页时还是 14,奇偶性不变。引擎跨分页携带索引。

颜色选择和暗色主题变体见 斑马纹食谱。本篇要点:表格的 属性(交替模式)在表格调用处声明,不是在行级别配置。

分页:真正难的部分

大多数 Go PDF 故事都在这里崩盘,也是 gpdf 设计回报最高的地方。

简单版:给表格塞超过单页容量的行,gpdf 自动分页。Header 切片在每个续页顶部重新绘制。 没有要打开的选项,没有要调用的方法。是布局引擎的默认行为。

详细版更有意思。块布局引擎 (document/layout/block.go) 用可用高度布局表格。正文放不下时返回结果会带 Overflow 字段 —— 同样的 Header、同样的 Footer剩余的正文行 组成的新 *document.Table。页面系统把已布局部分写出到当前页,开新页,再用新页的可用高度把溢出表格喂回布局引擎。直到溢出为空。

两个推论:

  1. 表头住在 tbl.Header 里,不在循环里。 因为溢出表格复用同一个 Header 切片,续页顶部自动重复表头。样式、列宽、所有都一样。
  2. 不存在"表头放不下"的边界情况。 布局引擎在测量正文行之前先为表头预留空间。如果一页连表头加至少一行正文都装不下,整张表被推到下一页。

表尾 —— 在 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.Widthdocument.Value 类型,可取 Pt / Mm / Cm / In / Em / Pct,以及特殊的 Auto。一张表里可以混用。Auto 列共享固定列与百分比列扣除后的剩余。比构建器的纯百分比模型更接近 CSS <col> 元素。

TableCell.ColSpanRowSpan 是整数,默认 1。例子里前三列合并写"合计",第四列放金额 —— 经典发票表尾。

document.Table.Footer[]TableRow,和表头一样每页重复。构建器 API 不暴露是因为短表格大多用不到 —— 真要用时已经离开"常见情况"区域了。

这是 gpdf 的整体模式:高层构建器优雅覆盖 90%,document 层在旁边等着另外 10%。不是两个库。同一份文档里可以混用构建器行和手搭行。构建器只是同一个 document.Table 节点的构造函数。

边框与盒模型

三个边框选项,三个不同任务:

template.WithTableBorder(spec)         // 整张表外框
template.WithTableCellBorder(spec)     // 每个单元格相同边框
template.WithTableBorderCollapse(true) // 合并相邻单元格边框

默认无边框。要外框加 WithTableBorder。要网格加 WithTableCellBorder。两者都加得到"框 + 网格"。BorderSpectemplate.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/gopdf50〜80 行手动手动更底层
johnfercher/maroto v2约 15 行自动行级 WithBackgroundColor 手动基于 gofpdf;API 漂亮但带依赖
unidoc/unipdf约 12 行自动行样式辅助函数商业许可

构建器行数比起来差距没那么大。真正的差异在用了 6 个月之后才显现。需求漂移时 —— 多了一列要不同对齐、报表要日文版、客户要在表尾打印行数 —— 用 gofpdfgopdf 每次都要碰行循环。用 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

⭐ 在 GitHub 加星 · 阅读文档

相关阅读