全部文章

把 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, ...) 通常会问三个问题:

  1. 为什么是 12?为什么不是 16、24,或者「想要多少都可以」?
  2. 这是 CSS Grid?Bootstrap?还是别的什么?
  3. 如果我的 span 总和不是 12 会怎样?

本文沿着设计决定一步步回答这些问题。所有判断都归结为一个原则:PDF 的渲染需要的是可预测,而不是自适应。Web 页面在调整尺寸时会重新流动,PDF 不会。这一个区别就消除了 Web 网格系统的大部分复杂度,让我们能交付一个体积更小的设计。

如果你只想知道「怎么用」,/blog/12-column-grid 的菜谱版更直接。本文谈的是「为什么长这样」。

设计 PDF 布局的三种选择

我们做高层 API 的时候,现实的选项只有三个:

  1. 绝对定位。「在 (72, 540) pt 处绘制文本」。Go 大多数低阶 PDF 库给的就是这个。最大的自由度,最差的人体工程。每个坐标都得自己算。
  2. 流式 + flexbox。内容自上而下堆叠,行内的子元素以 grow/shrink 比例横向分配。功能强但布局过程不平凡——需要约束求解器,舍入误差还会累积。
  3. 固定网格 + 比例。一页是若干行的堆叠,一行被切成 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 保留了什么

只有三件事:

  1. 12。分母。仪表盘上唯一的数字。
  2. 整数 1〜12 的 span。不是分数,不是 CSS 单位。r.Col(4, ...) 占行的 4/12。
  3. 思维模型。一页是若干行的堆叠,一行被切分成若干列。和你写了十年 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-fluidcol-md-offset-3col-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 的模型确实有两种场景不合适,老实列出来:

  1. 需要严格像素级精度的宽度。「这一列必须正好 73.5pt」。Pct 几乎做不到(73.5 / 总宽 × 12 极少是整数)。少数需要固定坐标的元素用 page.Absolute(...),其他交给网格。两者可以同页混用。
  2. 需要报纸式的栏内续接。一段文字在第一栏满了之后续到第二栏。网格不做这个。我们目前还没有栏式续接的文本引擎。需要的话欢迎提 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

⭐ Star on GitHub · 阅读文档

接下来读