把 Bootstrap 思维带进 PDF:gpdf 的 12 栏网格
gpdf 的 PDF 布局借鉴了 Bootstrap 的 12 栏网格——只保留整数 span 模型,丢弃断点、间距、排序等所有响应式包袱。本文剖析这一设计判断。
TL;DR
gpdf 沿用了 Bootstrap 的 12 栏网格。选 12,是因为它能整除成 1、2、3、4、6——也就是你真正会用的所有切分。我们保留了整数 span 的模型,把其他都扔掉了:没有断点、没有 gutter、没有 order、没有 auto-fill。 一页是若干行的堆叠,一行是一个水平 Box,行内的列以「12 分之几」的方式取宽。
仅此而已。实现大约 30 行 Go 代码。有意思的是「我们没有移植什么」。
为什么写这篇文章
gpdf 是一个 Go 的 PDF 生成库。高层布局 API 是一组 Builder:page.AutoRow → r.Col(span, fn) → c.Text/Image/Table。新用户看到 r.Col(4, ...) 通常会问三个问题:
- 为什么是 12?为什么不是 16、24,或者「想要多少都可以」?
- 这是 CSS Grid?Bootstrap?还是别的什么?
- 如果我的 span 总和不是 12 会怎样?
本文沿着设计决定一步步回答这些问题。所有判断都归结为一个原则:PDF 的渲染需要的是可预测,而不是自适应。Web 页面在调整尺寸时会重新流动,PDF 不会。这一个区别就消除了 Web 网格系统的大部分复杂度,让我们能交付一个体积更小的设计。
如果你只想知道「怎么用」,/blog/12-column-grid 的菜谱版更直接。本文谈的是「为什么长这样」。
设计 PDF 布局的三种选择
我们做高层 API 的时候,现实的选项只有三个:
- 绝对定位。「在 (72, 540) pt 处绘制文本」。Go 大多数低阶 PDF 库给的就是这个。最大的自由度,最差的人体工程。每个坐标都得自己算。
- 流式 + flexbox。内容自上而下堆叠,行内的子元素以 grow/shrink 比例横向分配。功能强但布局过程不平凡——需要约束求解器,舍入误差还会累积。
- 固定网格 + 比例。一页是若干行的堆叠,一行被切成 N 个等宽槽位。每列占用整数个槽位。宽度 =
槽位 / N × 行宽。不需要约束求解器。没有 grow/shrink。
我们选了第三个。Bootstrap 十多年前出于同样的理由做了相同的选择:实务里要用到的布局,绝大多数都是整数比例的布局。两等分。1/3 + 2/3。一行四张卡片。25-50-25 的行。这些都不需要约束求解器。
剩下的问题是:N 取多少?
为什么是 12
12 不是魔法,但也不是随便。想想文档里你真正想要的整数切分:
- 2 列 —— 左右半分
- 3 列 —— 三等分(三卡片画廊)
- 4 列 —— 四等分(KPI 横条)
- 6 列 —— 六等分(窄边栏,偶尔用)
- 12 列 —— 十二等分(很少用,细分隔条)
12 的因数:1、2、3、4、6、12。也就是说六分之一以内所有实用的整数切分都进来了。10 给不了三等分。16 同样给不了。24 全都能给,但认知负担翻倍——你看到 r.Col(8, ...) 还得想这是 1/3(24 ÷ 3)还是 2/3(8 ÷ 12)。12 是覆盖人们常用切分的最小数。
Bootstrap 在 2011 年也是出于这个理由落到 12 上。后来 CSS Grid 更进一步,让你直接写 1fr 2fr 1fr,去掉了魔法数字。但比例并不免费——它把代价转嫁给读代码的人。r.Col(4, ...) 直接告诉你「行的三分之一」。r.Col(2fr, ...) 必须把所有兄弟都看一遍才能确定含义。
对于布局固定、靠肉眼调试的 PDF 而言,整数模型更合适。
我们从 Bootstrap 保留了什么
只有三件事:
- 12。分母。仪表盘上唯一的数字。
- 整数 1〜12 的 span。不是分数,不是 CSS 单位。
r.Col(4, ...)占行的 4/12。 - 思维模型。一页是若干行的堆叠,一行被切分成若干列。和你写了十年 HTML 的网格形状一致。
到这里都跟 Bootstrap 一样。下面才是有趣的部分。
我们扔掉了什么
断点
Bootstrap 的 col-md-6 col-lg-4 让一列在平板上占一半,在桌面上占三分之一。Web 里有用。PDF 里没意义。PDF 的页面是固定画布,没有 viewport 可以查询,没有 resize 事件,没有 media query。我们把断点彻底删掉了。
省下来的远比看起来多。CSS 框架之所以要发 col-xs-*、col-sm-*、col-md-*、col-lg-*、col-xl-* 五份相同的列类,根源就是断点。gpdf 全没有。API 是 r.Col(span int, fn func(*ColBuilder)),一个签名,一个心智槽。
gutter
Bootstrap 的行默认在列间加水平 padding。PDF 不需要默认 gutter,因为列间间距完全取决于内容——紧凑表格用 0,hero 区用 24pt,发票行可能只要 0.5pt 的分隔线。所以我们让间距显式。
要 gutter 就自己加:在列之间塞 c.Spacer(...),或者把内层包进带 padding 的 Box。网格本身从不替你插入你没要求的像素。对一切以点为单位的印刷介质来说,「无 gutter 默认」才是对的默认值。
order
CSS 可以用 order: 2 调换列的视觉顺序。这是为响应式设计:同一份 DOM 在小屏上换一种顺序展示。对 PDF 没用。文件里列出现的顺序就是页面上呈现的顺序。我们连考虑都没考虑过。
auto-fill / auto-fit
CSS Grid 里有 repeat(auto-fit, minmax(200px, 1fr))——按 200px 起的最小列宽尽量塞。Web 上的画廊很漂亮。但 PDF 在编译时就知道页面宽度,不需要让布局引擎来猜。
要四张卡片的行就 r.Col(3, ...) 写四遍,要六张就 r.Col(2, ...) 写六遍。「auto」版本就是用户自己代码里的一个 for 循环:
for _, item := range items {
r.Col(3, func(c *template.ColBuilder) {
c.Text(item.Name)
})
}
三行。不需要写进框架。
强制 span 总和
可能让人意外的一条:gpdf 不要求列的 span 总和等于 12。这是有意的。
page.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) { c.Text("左 1/3") })
r.Col(4, func(c *template.ColBuilder) { c.Text("中 1/3") })
// 总和 = 8。右边的 1/3 就是空的。
})
库把每列当作 span/12 × 行宽 来处理,仅此而已。一行写 4 + 4,右边那个槽位就是空的。写 7 + 8,第二列就溢出到行外——这也是有意的,因为有时候你就想要溢出(比如和比页面更宽的版心对齐)。span 会被夹到 1〜12(Col(0, ...) 变成 Col(1, ...),Col(99, ...) 变成 Col(12, ...),参见 gpdf/template/grid.go:120),但不会自动换行,不会自动平衡。
Bootstrap 旧版「总和超过 12 就折到下一行」是为响应式问题设计的。PDF 没那个问题。我们用一个更简单的合同代替:写什么就出什么。
container、fluid 模式、no-gutters、offset、push/pull
通通没有。container-fluid、col-md-offset-3、col-md-push-2,所有 Bootstrap 工具类等价物都没在 gpdf。要把列向右推,自己包:在前面塞一个空的 r.Col(3, ...)。多写八个字符,不引入新概念。
gpdf vs Bootstrap vs CSS Grid
| 特性 | Bootstrap (CSS) | CSS Grid (CSS) | gpdf (Go) |
|---|---|---|---|
| 网格大小 | 12 列 | 任意 (grid-template-columns) | 12 列 |
| 单位 | 类名 | 比例 (fr)、px、% | 整数 span 1〜12 |
| 断点 | 5 档 (xs/sm/md/lg/xl) | 通过 media query | 无 |
| 默认 gutter | 有 (gx-* 控制) | 无 | 无 |
| 视觉重排 | order-* | order 属性 | 无 |
| auto-fill | 无 | 有 | 无 |
| 总和 > 12 折行 | 有(旧版)/ 无(flex) | 不适用 | 无(允许溢出) |
| 实现规模 | 约 3,000 行 SCSS | 浏览器内 | 约 30 行 Go |
「30 行」是真数。打开 gpdf/template/grid.go 数一下:一个常量(gridColumns = 12)、一个把整数夹到范围内的 Builder 方法、一遍 build 过程为每行发出一个水平 Box,子元素的宽度是 Pct(span/12*100)。没有度量过程,没有 flex 算法,没有再平衡。宽度的算术就是算法本身。
内部到底怎么做
调用 r.Col(4, fn) 时,gpdf 在行里 append 一个 colEntry{span: 4, fn: fn}。文档构建时,每个 entry 变成一个 Width: document.Pct(33.333…) 的 document.Box,列内容嵌套其中。行本身是 Direction: DirectionHorizontal 的 Box。PDF Writer(Layer 1)按文档顺序遍历 Box,发出内容流;布局引擎(Layer 2)解析宽高;网格(Layer 3)做整数到百分比的转换。仅此而已。
之所以能用 30 行打住,是因为百分比与整数在布局边界处的合成不会带来舍入误差。列里嵌列、再嵌列,最终是 float64 上的一串 Pct 乘法。即便嵌套很深,误差也远低于一个排印点。
想看完整链路,gpdf 为什么比同类快 10 倍 解释了渲染管线。网格是其中最便宜的一层——M1 上单页约 13 µs,网格只占其中几百纳秒。
一份完整可运行的示例
4/8 分割的表头、12 全宽的表格行、3/3/3/3 的 KPI 横条:
package main
import (
"os"
"github.com/gpdf-dev/gpdf/document"
"github.com/gpdf-dev/gpdf/template"
)
func main() {
doc := template.NewDocument(document.PageSize(document.A4))
doc.Page(func(p *template.PageBuilder) {
// 4/8 分割:左 logo,右 地址
p.AutoRow(func(r *template.RowBuilder) {
r.Col(4, func(c *template.ColBuilder) {
c.Text("ACME, Inc.", template.FontSize(18), template.Bold())
})
r.Col(8, func(c *template.ColBuilder) {
c.Text("北京市朝阳区工业大道 123 号", template.AlignRight())
c.Text("邮编 100001", template.AlignRight())
})
})
p.Spacer(document.Mm(10))
// 全宽(12 span 1 列)的表格
p.AutoRow(func(r *template.RowBuilder) {
r.Col(12, func(c *template.ColBuilder) {
c.Table([]string{"品名", "数量", "金额"}, [][]string{
{"商品 A", "2", "¥1,000"},
{"商品 B", "1", "¥2,500"},
})
})
})
p.Spacer(document.Mm(10))
// KPI 横条:3 span × 4 列
kpis := []struct{ label, value string }{
{"小计", "¥4,500"},
{"增值税 (10%)", "¥450"},
{"运费", "¥0"},
{"总计", "¥4,950"},
}
p.AutoRow(func(r *template.RowBuilder) {
for _, k := range kpis {
k := k
r.Col(3, func(c *template.ColBuilder) {
c.Text(k.label, template.FontSize(8))
c.Text(k.value, template.FontSize(14), template.Bold())
})
}
})
})
f, _ := os.Create("invoice.pdf")
defer f.Close()
doc.Render(f)
}
这是真正能跑的程序。go get github.com/gpdf-dev/gpdf 然后运行,当前目录就会出现 invoice.pdf。M1 上渲染时间约 130 µs。
整数模型不合适的场景
整数十二分之 n 的模型确实有两种场景不合适,老实列出来:
- 需要严格像素级精度的宽度。「这一列必须正好 73.5pt」。
Pct几乎做不到(73.5 / 总宽 × 12极少是整数)。少数需要固定坐标的元素用page.Absolute(...),其他交给网格。两者可以同页混用。 - 需要报纸式的栏内续接。一段文字在第一栏满了之后续到第二栏。网格不做这个。我们目前还没有栏式续接的文本引擎。需要的话欢迎提 issue——我们知道这块缺。
除此之外,发票、报表、合同、宣传册、提案——12 栏网格比 CSS 还更贴合,不会更松。
常见问题
问:能把 12 改成 24 之类吗?
不能。gridColumns 是常量,改了就废掉所有现有模板。我们一次决定 12,就固定了。
问:想在列里嵌套行怎么办?
可以。c.AutoRow(...) 在列内创建子行。子行内的 1〜12 是相对父列宽度的,不是页面宽度。每一层都是「相对父级的 Pct(span/12 × 100)」,所以嵌套合成很干净。
问:横向页面也行吗?
行。网格与页面尺寸无关。r.Col(6, ...) 永远是行的一半,不论行宽是 210mm(A4 纵向)还是 297mm(A4 横向)。
问:为什么没有 r.Col2(span, span, fn1, fn2) 这种两列快捷写法?
为了少写一行去扩 API 表面,性价比太低。如果你在重复同一个行模式,写一个接收 *template.PageBuilder 的 Go 函数自己加进去就好。网格保持最小,用户层模式才能不冲突地生长。
问:CSS Grid 的 grid-area、命名网格线呢?
gpdf 没有,路线图也没有。对 PDF 来说性价比不划算。
小结
12 栏网格是「真实文档需要的切分」用最小成本提供的布局原语。我们从 Bootstrap 借了数字 12,保留整数模型,把断点、gutter、order、auto-fill、span 总和强制以及响应式 Web 的其余包袱全扔了。剩下的是一个常量、一个 Builder 方法、一个宽度公式——大约 30 行 Go。它通过嵌套优雅合成,与 Absolute 在网格无法表达的少数场合和平共存,从不悄悄重排你写的内容。
试试 gpdf
gpdf 是 Go 的 PDF 生成库。MIT、零依赖、原生 CJK 支持。
go get github.com/gpdf-dev/gpdf
接下来读
- gpdf 的 12 栏网格怎么用? —— 菜谱版,更多代码示例
- gpdf 为什么比同类快 10 倍 —— 渲染管线内部解析
- Quickstart —— 五分钟生成第一份 PDF