全部文章

在 Go PDF 中正确实现页码、页眉和页脚

用 gpdf 在 Go PDF 中加入页眉、页脚和『Page X of Y』:两个 builder 方法和两阶段分页器自动填充总页数,无需 hack。

一份 60 页的财务报告。有人在打印队列里打开第 12 页,问了一个问题:现在是第几页,还剩多少页?如果页脚只写 12,谁都不知道。它应该写 12 / 60

60 这一边,是大多数 PDF 库出错的地方。要么写页脚时根本拿不到总页数,要么藏在 AliasNbPages 这种事后替换的 token 里,要么把文档渲染两遍再扔掉第一遍。

gpdf 用两个 builder 方法和一个内部两阶段分页器把它解决得干净利落。本文介绍 API 长什么样、内部怎么实现、以及目前唯一一个值得吐槽的小毛刺。

TL;DR

  • doc.Header(fn)doc.Footer(fn) 注册一个在每一页运行的闭包。
  • 闭包里用的是和正文一样的 12 栏网格。
  • c.PageNumber() 输出当前页码,c.TotalPages() 输出总页数。
  • 总页数会在分页完成后的第二阶段自动解析。不需要自己写双遍构建逻辑。
  • 一个粗糙点:没有 c.PageNumberOf(total) 这种把 "3 of 12" 当成一个内联字符串的辅助方法。要拼出来得用三列。下面会讲。

文中所有代码都来自 gpdf/_examples/builder/26_page_number_test.go,是测试套件的一部分,能编能跑。

一个文件搞定一切

完整可运行的程序。存为 main.go,执行 go run main.go,得到 4 页的 PDF,每页页眉显示总页数,页脚显示当前页码。

package main

import (
    "os"

    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/pdf"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    doc := template.New(
        template.WithPageSize(document.A4),
        template.WithMargins(document.UniformEdges(document.Mm(20))),
    )

    doc.Header(func(p *template.PageBuilder) {
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(6, func(c *template.ColBuilder) {
                c.Text("季度报告", template.Bold(), template.FontSize(10))
            })
            r.Col(6, func(c *template.ColBuilder) {
                c.TotalPages(template.AlignRight(), template.FontSize(9),
                    template.TextColor(pdf.Gray(0.5)))
            })
        })
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Line(template.LineColor(pdf.RGBHex(0x1565C0)))
                c.Spacer(document.Mm(3))
            })
        })
    })

    doc.Footer(func(p *template.PageBuilder) {
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Spacer(document.Mm(3))
                c.Line(template.LineColor(pdf.Gray(0.7)))
                c.Spacer(document.Mm(2))
            })
        })
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(6, func(c *template.ColBuilder) {
                c.Text("Generated by gpdf", template.FontSize(8),
                    template.TextColor(pdf.Gray(0.5)))
            })
            r.Col(6, func(c *template.ColBuilder) {
                c.PageNumber(template.AlignRight(), template.FontSize(8),
                    template.TextColor(pdf.Gray(0.5)))
            })
        })
    })

    for _, title := range []string{"引言", "背景", "分析", "结论"} {
        page := doc.AddPage()
        page.AutoRow(func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Text(title, template.FontSize(18), template.Bold())
                c.Spacer(document.Mm(5))
                c.Text(title + " 章节正文。")
            })
        })
    }

    out, err := doc.Generate()
    if err != nil {
        panic(err)
    }
    _ = os.WriteFile("report.pdf", out, 0o644)
}

生成 4 页,页眉右上显示 4,页脚右下显示 14。代码里从没告诉 gpdf 文档有 4 页 —— gpdf 也是在分页完成后才知道的。

为什么 "Page X of Y" 难做

Y 的难点在于,画第 1 页时还不知道它是多少。50 页的报告里,第 47 页可能因为表格行无法放下而被拆到下一页。总数 50 只有在分页器跑完才能确定。但第 1 页的页脚早就画完了。

每个 PDF 库都会撞到这堵墙。Go 几个主流库的处理方式:

"Page X of Y" 实现
gofpdfpdf.AliasNbPages("{nb}"):在正文中写 {nb} 作为字面量,事后对 PDF 流做字符串替换。能用,但要记得调用,而且占位符是魔法字符串。
go-pdf/fpdfgofpdf 的 fork,机制相同。
signintech/gopdf没有原生支持。自己渲染一遍数页数,再重新渲染一遍。
maroto v2提供和 gpdf 类似的 Header/Footer 注册,内部也是两阶段。但底层是 gofpdf,所以在常见工作负载下比 gpdf 慢约 10 倍。
gpdfc.PageNumber() / c.TotalPages():类型化方法调用,无魔法字符串,由内部第二阶段解析。

只有 gpdf 把页码原语放在类型化 builder API 里。gofpdf 里把 {nb} 打错成 {nB},PDF 上就直接印出 {nB}c.TotalPages() 最糟糕只会是忘记调用 —— 那就什么都不显示,而不是显示错的数字。

第二阶段是怎么跑的

内部 c.PageNumber() 会渲染成一个占位字符串 —— 没有任何真实字体字形会匹配的哨兵值。分页器布局完所有页面、确定总数后,会遍历已渲染的文本指令做替换:

  1. 第一阶段 (分页): 渲染每一页,包括页眉页脚,把 PageNumberTotalPages 当作固定宽度的 token 处理。计算总页数。
  2. 第二阶段 (解析): 回溯页树,找到每个哨兵值,替换为实际页码/总数。

占位符的宽度按预期最大页数预留 (启发式),所以替换后布局不会偏移。右对齐的页码在 9 → 10 桁数变化时仍然对齐。

第二阶段你不用写。文档不用渲染两遍。调用 doc.Generate() 拿字节就好。

页眉和页脚就是普通布局

从 gofpdf 过来的人在这里会迷惑。那边 SetHeaderFunc 是在固定 Y 坐标上回调,用绝对定位的 Cell(...) 放文字。在 gpdf 里,页眉的闭包收到的是 *template.PageBuilder —— 和正文同一个类型。网格相同,行列相同,样式选项相同。

doc.Header(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(2, func(c *template.ColBuilder) {
            c.Image("logo.png", template.ImageHeight(document.Mm(12)))
        })
        r.Col(8, func(c *template.ColBuilder) {
            c.Text("Annual Report 2026", template.Bold(), template.FontSize(14))
        })
        r.Col(2, func(c *template.ColBuilder) {
            c.TotalPages(template.AlignRight())
        })
    })
})

左 logo、中标题、右总页数。三列宽度合 12,和正文行规则一致。

页眉高度自动测量。gpdf 在正文布局前执行一次页眉闭包,量出渲染高度,从每一页的可用正文高度里减去。页脚同理。不需要传 headerHeight。给页眉加一行,正文就会自动缩小。

页眉和页脚在所有页面上重复,包括内容溢出生成的页面。如果长表格溢出到第 12 页,第 12 页同样有页眉页脚。目前没有 "仅首页" 标志位 (见下文)。

粗糙点:把 "Page X of Y" 写成一行

这是我觉得 API 还可以做得更好的地方。没有 c.PageOf("Page %d of %d") 这种辅助方法。要得到一个字面字符串 "Page 3 of 12",因为 c.Text()c.PageNumber() 是独立的列子元素,所以得用列拼起来:

r.Col(12, func(c *template.ColBuilder) {
    c.AutoRow(func(r *template.RowBuilder) {
        r.Col(3, func(c *template.ColBuilder) {
            c.Text("Page", template.AlignRight())
        })
        r.Col(2, func(c *template.ColBuilder) {
            c.PageNumber(template.AlignCenter())
        })
        r.Col(2, func(c *template.ColBuilder) {
            c.Text("of", template.AlignCenter())
        })
        r.Col(3, func(c *template.ColBuilder) {
            c.TotalPages(template.AlignLeft())
        })
        r.Col(2, func(c *template.ColBuilder) {})
    })
})

能用,看上去也还行。但本来一行格式化字符串能搞定的事情扩成了四列,是一道纸割。我们在考虑加 c.PageOf(format string, opts ...TextOption),用 fmt.Sprintf 风格的 %d 占位。如果你对 API 形态有想法,GitHub issue 上留言。

目前的实用快捷做法是去掉 "Page" 用斜杠隔开:

r.Col(6, func(c *template.ColBuilder) {
    c.PageNumber(template.AlignRight())
})
r.Col(1, func(c *template.ColBuilder) {
    c.Text("/", template.AlignCenter())
})
r.Col(5, func(c *template.ColBuilder) {
    c.TotalPages(template.AlignLeft())
})

3 / 12 作为页脚足够清晰。要写 第 3 页 / 共 12 页 也是同样的方法,多塞几个 c.Text 即可。

常见模式

实务中经常用到的几种。

标题下加分割线。 多加一个 AutoRow,里面放 c.Line()。开头的示例就是这样。

居中的"保密"页脚。 一行一列、AlignCenter

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("机密 — 内部使用",
                template.AlignCenter(),
                template.FontSize(8),
                template.TextColor(pdf.Gray(0.5)))
        })
    })
})

国内公司的报告里经常还会加"打印日期: 2026年5月19日""文档编号: DOC-2026-0517"之类的几行。再叠几个 c.Text(...) 就好。

左 logo、右页码。 8/4 或 6/6 分列。左边 c.Image(...),右边 c.PageNumber()AlignRight

页脚显示"下接下页"。 当前不支持。页眉/页脚闭包只收到 PageBuilder,不知道当前页索引,所以无法分支判断"是不是最后一页"。要在正文里加,反而需要事先知道总页数,矛盾。在功能需求清单里。

首页用不同的页眉。 同样原因目前不支持。变通办法是首页正文顶部塞一个 spacer 让页眉看上去为空,从第二页开始恢复 —— 比较丑。doc.HeaderOn(pages, fn) 变种在设计中。

CJK 直接能用

gpdf 不依赖 CGO 就能做 TrueType 字体子集化。中文、日文、韩文可以直接 c.Text(...)。没有 AddUTF8Font 之类的仪式感操作,只要字体覆盖了你要的字符,就不会出豆腐字。

doc := template.New(
    template.WithPageSize(document.A4),
    template.WithFont("NotoSansSC", notoSansSCRegular),
)

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("机密", template.FontFamily("NotoSansSC"), template.FontSize(8))
        })
        r.Col(6, func(c *template.ColBuilder) {
            c.PageNumber(template.AlignRight(), template.FontSize(8))
        })
    })
})

最终 PDF 里嵌入的子集只包含"实际用到的字形"。60 页报告页脚只有 "机密",那就只从 NotoSansSC 里嵌入两个字形,不是 20000 个。在增值税电子发票之类需要 PDF/A-3 且对文件大小敏感的场景里很有用。

性能

如果你要规模化生成 PDF,这部分重要。

第二阶段不是免费的,但很便宜。100 页文档在 M1 上第二阶段不到 50µs。占总生成时间不到 1%。gpdf 单页基准 13µs,100 页基准 683µs。页码解析是与页面复杂度无关的常数因子。

对比一下,gofpdf 的 AliasNbPages 是在压缩决策之后对整个内容流做字符串替换,包含别名的流要重新压缩。在 gofpdf 自己的基准里大概占 100 页文档总时间的 2〜4%。gpdf 的替换发生在流编码之前,所以更快。

每天百万级 PDF 生成时差距很明显。每天十个的话无所谓。

FAQ

页眉/页脚高度算在页边距里吗? 不算。gpdf 量出页眉和页脚的实际高度,正文可用高度按 pageHeight - top_margin - headerHeight - footerHeight - bottom_margin 计算。上边距 20mm、页眉 15mm,正文从页顶 35mm 开始。

能让每页页眉高度不同吗? 不能。页眉闭包只评估一次用于测量,结果对整个文档固定。要可变高度的话,只能设一个最大高度并用空白调整内容。

正文为空的页面会显示页眉/页脚吗? gpdf 不生成空页。正文放得下 3 页就只有 3 页。页眉页脚就只在那 3 页上出现。

纵横混排文档里横向页只想要不同页眉? 按页设置 WithPageSize(...) 改方向是支持的,但页眉/页脚闭包对所有页面相同。实务上把页眉做成在两种方向下都看得过去的居中布局比较稳。

JSON 模板入口能用吗? 能用。JSON schema 里有 headerfooter{"type": "pageNumber"}{"type": "totalPages"}gpdf/_examples/json/26_page_number_test.go 验证 JSON 入口和 builder 入口生成相同的 golden PDF。

Go text/template 入口呢? 能用。gpdf/_examples/gotemplate/26_page_number_test.go 跑同一场景。无论是 builder、JSON 还是 Go template,底下都是同一个两阶段分页器。

下一步

页眉、页脚、页码是报告里最不起眼的部分 —— 但也是让报告看起来"做完了"的关键。如果你之前都是在低级 PDF 库上手写这部分,本文几行代码就能搞定。复制示例改改字符串,发布。

未解决的问题 —— c.PageOf(...) 单字符串格式化、首页不同页眉、"是否最后一页"检测 —— 在排队中。任何一个挡住你了,去 GitHub issue 提一下。具体的用例比抽象的需求更能决定 API 长什么样。

用 gpdf 试试

gpdf 是 Go 的 PDF 生成库,MIT 许可,零依赖,CJK 支持。

go get github.com/gpdf-dev/gpdf

⭐ 在 GitHub 上 Star · 阅读文档