全部文章

如何在 gpdf 中设置自定义纸张尺寸?

document.Size 以点为单位,用 Mm(100).Resolve(0, 0) 换算后传给 WithPageSize。含标签、JIS B5 与陷阱。

发布于 2026年11月10日
作者: Taiki Noda

换个说法提问

你需要的不是 A4,也不是 Letter,而是 4×6 英寸的快递面单、80mm 宽的热敏小票、100×148mm 的明信片,或者 JIS B5——麻烦的是,它和 document.B5 常量并不是一回事。WithPageSize 接收 document.Size。怎样用任意尺寸构造它?构造时又会踩到哪些坑?

TL;DR

document.Size 只是两个以 PDF 点(1/72 英寸)为单位的 float64 字段。用 Resolve 换算后传入即可:

label := document.Size{
	Width:  document.In(4).Resolve(0, 0), // 288 pt
	Height: document.In(6).Resolve(0, 0), // 432 pt
}
doc := template.New(template.WithPageSize(label))

正常路径就这么简单。本文剩下的部分讲的是三种出错方式。

代码

一个完整的 4×6 英寸快递面单程序。已在 gpdf v1.0.17 上验证,输出的 /MediaBox 为 [0 0 288 432]。

package main

import (
	"log"
	"os"

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

// mm converts millimetres to PDF points, the unit document.Size uses.
func mm(v float64) float64 { return document.Mm(v).Resolve(0, 0) }

// in converts inches to PDF points.
func in(v float64) float64 { return document.In(v).Resolve(0, 0) }

func main() {
	// A 4×6 inch shipping label: 288 × 432 pt.
	label := document.Size{Width: in(4), Height: in(6)}

	doc := template.New(
		template.WithPageSize(label),
		template.WithMargins(document.UniformEdges(document.Mm(6))),
	)

	page := doc.AddPage()
	page.AutoRow(func(r *template.RowBuilder) {
		r.Col(12, func(c *template.ColBuilder) {
			c.Text("SHIP TO", template.Bold(), template.FontSize(10))
			c.Text("Jane Doe", template.FontSize(18), template.Bold())
			c.Text("742 Evergreen Terrace")
			c.Text("Springfield, OR 97477")
			c.Spacer(document.Mm(6))
			c.QRCode("TRACK-1Z999AA10123456784", template.QRSize(document.Mm(40)))
		})
	})

	data, err := doc.Generate()
	if err != nil {
		log.Fatal(err)
	}
	if err := os.WriteFile("label.pdf", data, 0o644); err != nil {
		log.Fatal(err)
	}
}

mm / in 这两个小辅助函数值得直接复制进项目。反正你迟早也会自己写。

为什么 Size 不是 Value

在 gpdf 的其他地方,你传入 document.Mm(20),换算成点的工作由布局引擎稍后完成。页边距、Spacer、二维码尺寸——全都是 document.Value。所以很自然会这样写:

document.Size{Width: document.Mm(100), Height: document.Mm(150)} // 编译不通过

编译不通过,因为 Size 的定义是:

type Size struct {
	Width, Height float64
}

原因在于 Size 是坐标系的最底层。Value 可以是 Pct 或 Em,解析它们需要父级尺寸或字号。而页面本身就是"父级",它上面没有任何东西可以作为百分比的基准。所以页面必须是具体数字,而 PDF 的 /MediaBox 用点来表示,这个数字自然就是点。

Resolve(parentSize, fontSize) 是布局引擎内部调用的方法。对于绝对单位,两个参数都会被忽略,所以 Resolve(0, 0) 的意思就是"给我点数"。换算系数是固定的:Mm ×2.83465,Cm ×28.3465,In ×72。

与之相伴的陷阱是,下面这段能编译通过:

document.Size{Width: 100, Height: 150} // 你以为是 mm

结果得到一张 35 × 53mm 的页面。没有错误,没有警告——100pt 本来就是合法的宽度。直到打开 PDF、看到面单像一张邮票,你才发现。如果这篇文章只记住一件事:除非你确实在用点思考,否则不要往 Size 里写裸数字。

方向与 JIS B5 问题

宽在前,高在后。要横向,可以自己交换,也可以调用 Landscape():

template.WithPageSize(document.A4.Landscape())

Landscape() 只在高大于宽时才交换,所以对已经横向的尺寸调用不会有任何变化。尺寸来自配置文件、不确定朝向时很方便。

顺便一提:gpdf 的 document.B5 是 ISO B5,176 × 250mm。日本办公室复印机说的 B5 是 JIS B5,182 × 257mm。如果你的文档要送到日本打印,JIS 尺寸需要自定义:

jisB5 := document.Size{Width: mm(182), Height: mm(257)}
jisB4 := document.Size{Width: mm(257), Height: mm(364)}

国内常见的增值税发票打印纸同样不在 ISO 标准尺寸之列,按实际纸张量好尺寸后用同样的方式构造即可。

一个文档只有一种尺寸

没有 AddPage(size)。WithPageSize 是文档级选项,所有页面——包括分页器自动创建的溢出页——都使用它。

如果要把一封 A4 的说明信和一张 4×6 面单放进同一个文件,就分别生成后合并:

merged, err := gpdf.Merge([]gpdf.Source{
	{Data: coverA4},  // 使用 document.A4 生成
	{Data: labelPDF}, // 使用 4×6 尺寸生成
})

合并后每一页都保留各自的 /MediaBox——我们验证过:先是 595.28 × 841.89,然后是 288 × 432。

JSON 模式只支持名称

JSON 模式的 page.size 只接受命名尺寸——"A0"–"A10"、"B0"–"B10"、"C0"–"C10"、"DL"、"Letter"、"Legal"——以及 "orientation": "landscape"。像 "100mm x 150mm" 这样的字符串会报错 unknown page size: "100mm x 150mm"。

不过从 Go 传入的选项会覆盖模式中的设置。所以模板保持 JSON,尺寸在代码里设置:

doc, err := gpdf.NewDocumentFromJSON(schema, data,
	gpdf.WithPageSize(document.Size{Width: mm(80), Height: mm(200)}),
)

NewDocumentFromTemplate 同样适用。

小页面与 20mm 默认页边距

这个坑是我们写这篇文章时亲自踩到的,所以单独成节。

template.New 默认四边 20mm 页边距。在 A4 上很合理。但在 50 × 30mm 的商品标签上,上下 40mm 的边距会让内容高度变成 −10mm。在 60 × 40 点的页面(又是裸数字陷阱)上配 5mm 边距,内容高度约 12pt——连一行 12pt 的文字都放不下。

在 v1.0.16 及之前,这两种情况下 Generate() 都不会返回错误。它根本不会返回。分页器会一直尝试放置一个永远放不下的行。在找到共同原因之前,我们有三个测试程序卡死,说实话那半小时真是白白浪费。更意外的是,在为修复截图时,第四个程序仍然卡死:一个比列还宽的单词被从中间断开,剩余文本每翻一页就悄悄多出一个空格,所以看起来从未“停住”。

从 v1.0.17 起,它会在几微秒内返回错误:

import "github.com/gpdf-dev/gpdf/document/layout"

data, err := doc.Generate()
if errors.Is(err, layout.ErrContentDoesNotFit) {
	// layout: content does not fit on an empty page: body area is
	// 28.35 x 0.00 pt on a 141.73 x 85.04 pt page; ...
	log.Fatal(err)
}

正文区域的数字通常足以找出问题。高度为零或负数,说明页边距、页眉或页脚吃掉了整个页面。

所以:对于小于 A6 的页面,仍然请显式设置页边距。错误只会告诉你哪里不对,不会替你选页边距。

小票的情况友好一些。80mm 宽、固定高度的页面会正常分页——在 80 × 120mm 的页面上放 40 行明细,得到 3 页。如果你想要一整条连续的纸,就必须预先确定高度;gpdf 不会根据内容自动拉长页面。

相关食谱

试试 gpdf

gpdf 是一个用于生成 PDF 的 Go 库。MIT 许可,零外部依赖,原生支持 CJK。

go get github.com/gpdf-dev/gpdf

⭐ 在 GitHub 上 Star · 阅读文档