Puppeteer PDF:页边距、字体与分页
用 Puppeteer 生成 PDF,明确设置纸张、打印 CSS 与分页规则,并按步骤排查中文字体方框。
BotBrowser Team
Puppeteer 可以把页面保存为 PDF,但 PDF 遵循的是打印排版规则,并不是把一张很长的截图装进文件。BotBrowser 配置文件可以提供声明好的字体集合,帮助重复浏览器渲染;它不会替你选择打印 CSS、等待应用数据,也不保证已安装字体文件包含所需的每个中日韩字形。明确设置纸张和页边距,等待页面自己的就绪条件及字体加载,再检查分页和代表性字形,最后才判断文件是否可交付。
要点
page.pdf()默认使用print媒体类型。纸张尺寸、页边距和分页规则应放在@media print与@page中。- 当输出要求明确时,设置
format、margin和printBackground;只有提供了页眉/页脚模板时才打开displayHeaderFooter。 - 等待应用数据就绪信号和
document.fonts.ready。字体完成加载不代表所选字体包含全部中、日、韩字符。 - 新规则优先使用
break-before、break-after和break-inside;page-break-*是打印 CSS 中的旧式别名。 - 字符变成方框时,应检查主机字体和实际生成的 PDF,不要直接假设 Chromium 未嵌入字体。
目录
page.pdf 实际渲染什么
page.pdf() 会以 CSS print 媒体类型打印当前页面。因此,@media print 中的规则可以改变 PDF,而不影响屏幕视图。page.screenshot() 生成的是窗口或页面的栅格图像;PDF 则把内容分页,并处理纸张尺寸、可打印区域和分页规则。截图最佳实践讨论的是如何保留某个视图的像素,解决的不是如何把内容排到纸张上。
这个区别能解释很多“PDF 忽略了我的 CSS”的反馈。样式表可能只写了屏幕规则;也可能 CSS 在 @page 中设置了纸张尺寸,而 PDF 调用又指定了 format。Puppeteer 提供 preferCSSPageSize,用于让 CSS 页面尺寸优先;否则 PDF 选项可能优先,内容也可能被缩放到纸张内。应选定一个纸张尺寸来源,而不是同时反复修改两层设置,直到结果看起来合适。
API 返回的是当前页面状态的 PDF 缓冲区。如果调用方没有先等待,Puppeteer 不会自动遵守应用的数据异步就绪约定。networkidle0 对某些页面有用,但它不等于通用的“报表已完成”:分析脚本、轮询或长连接可能在报表已经可用后仍保持网络活动。使用选择器或应用事件(例如 data-report-ready="true")可以把完成条件绑定到实际报表数据。
设置纸张、页边距和打印颜色
最常影响 PDF 是否可用的选项不多,但应明确设置:
| 选项 | 控制内容 | 实用做法 |
|---|---|---|
format | A4、Letter 等命名纸型 | 输出规范指定纸型时显式设置。 |
margin | 上、右、下、左页边距 | 使用 mm 或 in 等单位,并和 @page 保持一致。 |
printBackground | 是否打印背景图形 | 色块或背景图承载信息时开启。 |
displayHeaderFooter | 是否打印页眉、页脚模板 | 需要页码或元数据时配合模板开启。 |
preferCSSPageSize | 是否优先使用 CSS @page 尺寸 | 纸张尺寸由样式表负责时使用。 |
Puppeteer 文档中的默认值并非设计建议:默认纸型是 Letter,没有设置页边距,背景打印关闭,页眉和页脚也关闭。这些值适合简单打印,却可能让默认 A4、彩色图表或页码的报表任务产生意外。把最终设置保存在测试或服务配置中,避免升级后“PDF”代表的输出悄悄变化,影响下游使用者。
import fs from 'node:fs/promises';
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({ headless: true });
try {
const page = await browser.newPage();
await page.goto('https://example.test/report/42', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-report-ready="true"]');
await page.evaluate(async () => {
await document.fonts.ready;
return document.fonts.status;
});
const pdf = await page.pdf({
format: 'A4',
margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
printBackground: true,
displayHeaderFooter: true,
headerTemplate: '<span></span>',
footerTemplate: '<span class="pageNumber"></span>/<span class="totalPages"></span>',
});
await fs.writeFile('report.pdf', pdf);
} finally {
await browser.close();
}
把示例 URL 和就绪选择器换成应用自己的路由与信号。本例让 API 控制纸型;如果生产样式表负责纸型,就声明 @page { size: A4; } 并设置 preferCSSPageSize: true。不要让两个层级在不知情的情况下设置不同尺寸。页眉和页脚模板是独立的小型 HTML 片段,页面普通样式表不能替代片段内的样式;需要控制外观时应使用内联样式。
页边距可以设置在 page.pdf() 参数里,也可以放进 @page。不要随手把两套值叠加:可打印区域取决于纸张尺寸、页边距和内容的共同作用,冲突的设置会让裁切原因难以判断。如果由 @page 管尺寸,就检查生成文件的尺寸并设置 preferCSSPageSize: true;如果应用契约由 API 的 format 和 margin 管理,打印样式表就专注于内容和分页规则。
纸型也会改变现有 CSS 尺寸的行为。format 选择标准纸张;输出契约要求自定义尺寸时,可以使用更底层的 width 和 height。PDF 选项文档说明,指定 format 后它会优先,因此同时传入三者并不会让自定义纸型“更明确”。应选择命名纸型或宽高组合,并通过相应纸型或 CSS 页面规则指定纵向/横向。生成一次 PDF 并检查实际纸张尺寸;不要从浏览器窗口大小推断,因为它是另一项输入。
对于多部分报表,pageRanges 可以只生成有代表性的页面,例如封面、密集表格和含较长中日韩文本的页面。修改 CSS 后,这比逐页打开大型文档更适合快速回归检查。但页码范围不是内容选择器:前面的章节多出一行,后续页码就可能移动。应同时保留完整页数检查和高风险页面抽查,防止样本正确却漏掉最后一节。
背景颜色也必须明确决定。printBackground 关闭时,彩色图表可能丢失填充色,但文字仍然存在。如果准确颜色属于交付要求,应在读者实际使用的颜色流程中检查 PDF。CSS 可以要求保留打印颜色,但 PDF 阅读器和打印机仍可能使用自己的颜色处理。不要只靠截图对比判断打印颜色是否正确。
页眉与页脚是独立的小型文档。开启 displayHeaderFooter 时应提供 headerTemplate 和 footerTemplate;文档列出的占位符包括 date、title、url、pageNumber 和 totalPages。页码对会打印或引用的 PDF 有用,而横跨整页宽度的 Logo 可能占据可打印区域,让正文被挤到下一页。模板应保持简洁,并在模板内部设置字体和间距;同时检查第一页、中间页和末页,因为长标题可能只在其中一页被裁切。单页收据如果没有可见内容,空模板可能比启用空页眉更清楚。
用打印 CSS 控制分页
打印样式表要定义允许在哪里分页、哪些内容应保持完整,以及哪些元素只应出现在纸面上。@page 设置纸张几何尺寸,@media print 则限定打印时生效的样式:
@page {
size: A4;
margin: 18mm 14mm;
}
@media print {
.chapter:not(:first-child) {
break-before: page;
}
.result-card,
figure,
table tr {
break-inside: avoid;
}
.screen-only {
display: none;
}
}
需要让章节从新纸张开始时使用 break-before: page。break-inside: avoid 适合卡片、图片或表格行等较小单元,避免它们被拆开后难以阅读。但这并不保证任意高度的元素都能放进一页:元素超过可用空间时,渲染器仍然必须安排其内容。不要让整段很长的报表章节都保持不可拆分,否则前一页可能留下大片空白,或者产生难以处理的溢出。
旧样式表中可能会出现 page-break-before、page-break-after 或 page-break-inside。这些旧属性是对应现代 break-* 属性的兼容别名;新规则应使用现代名称。page-break-before: always 也不能替代对实际分页边界的检查,页边距、标题前的内容和元素高度都会影响分页位置。
如果 PDF 里的“下一页”按钮没有产生预期的分页,可以按此顺序排查:
- 确认 PDF 调用使用打印媒体类型,并确认分页规则位于该路由确实加载的样式表中。
- 检查
@page与 API 的format或margin是否矛盾,只保留一个权威设置层。 - 查看打印媒体下的计算样式和元素尺寸。只针对屏幕的选择器或未加载的打印样式表,都会让分页规则失效。
- 查找设置了
break-inside: avoid的高大父元素,把规则下移到真正需要完整保留的较小单元。 - 重新渲染受影响的页面范围,并检查分页边界附近的标题、表格和页脚。
长表格中,重复表头和避免拆分行是两个不同目标。可以把 thead 声明为打印时重复的表头组,并给行设置 break-inside: avoid;这两项都不保证整张表一定留在同一页。受控报表应使用跨多页的夹具测试,其中包含短行、高行和章节标题,避免只用一页样例就让样式变更通过。
分页也会暴露屏幕上看不到的问题。适合宽屏窗口的 flex 或 grid 行,按纸张宽度打印后可能变高;固定高度面板可能在打印字体换行后裁掉文本;页底附近的标题可能和后续正文分开。打印规则可以用 break-after: avoid-page 让标题与下一块内容靠在一起,也可以隐藏仅用于屏幕的控件、导航和粘性元素。应在实际生成 PDF 的同一浏览器版本中检查打印媒体下的计算样式,因为屏幕样式的结果无法说明打印时哪条规则胜出。
不要用分页规则弥补尚未就绪的数据。如果图表或表格在文档发出就绪信号后才填充,下一次运行时页数会变化,固定分页看起来就像不稳定。就绪条件应代表报表数据真的可用;样式表应表达预期分页方式。把两者分开,才能区分迟到的网络响应和错误的 break-before。
排查中日韩字形并验证文件
中文、日文或韩文字符显示成方框,通常表示当前字体路径没有提供该字形。原因可能是缺少字体家族、字形覆盖不完整、网页字体尚未加载,或主机使用了不同的回退字体。只看到 document.fonts.status === "loaded" 还不够:它表示字体加载集合的状态,并不证明某个字体文件包含文档里的全部字符。
先准备一小段测试文本,覆盖简体和繁体中文、日文假名、韩文谚文、标点以及实际文档使用的符号。等待页面的明确就绪信号和 document.fonts.ready,然后检查主机字体清单与 PDF。在 Linux 执行环境中,fc-match "Noto Sans CJK SC" 会显示 Fontconfig 为该字体家族匹配的已安装字体;fc-list :lang=zh 会列出声明支持中文的已安装字体。这些命令检查的是主机清单,不是每个字形最后选择的字体,因此它们是诊断步骤,不能证明 PDF 的最终内容。
接着把含测试文本的 PDF 页面渲染成图像,检查实际输出。pdffonts report.pdf 可以显示 PDF 引用了哪些字体资源以及它们是否嵌入;pdftoppm -f 1 -singlefile -png report.pdf /tmp/report-page 可以生成待检查页面。字体出现在 PDF 资源表中,不代表它就是某个字符的正确回退字体;应查看字形本身,而不只看元数据。需要稳定生成中日韩文字时,应锁定浏览器版本,并确保每个执行环境都提供相同字体集合。字体加载与布局稳定性指南说明字体晚到如何移动文字;PDF 生成在相同字体选择问题上又增加了打印媒体和分页。
字体清单是渲染环境的一部分。BotBrowser 配置文件可以声明字体集合,但主机仍须让渲染器能访问这些文件,而且文件本身必须覆盖目标文字体系。如果同一份报表在本机与 CI 上不同,先比较实际字体清单和浏览器版本,再调整行高或页边距。浏览器版本锁定与更新介绍的是版本控制;锁定浏览器二进制不会安装缺失字体,也不会锁定操作系统字体库。
文本提取是另一种有用的辅助检查。pdftotext report.pdf - 可以查看结果是否包含预期字符及合理的阅读顺序,但不能证明 PDF 阅读器会用预期形状绘制这些字。PDF 可能含有可提取的 Unicode,却把字符显示为回退方框;也可能视觉正常,但对辅助工具不友好的提取顺序。应把视觉检查、文本提取、页数和字体资源检查视为不同信号,它们分别回答文件质量的不同问题。
如果文件差异只体现在换行或页数上,先和最小两页夹具比较,再改页边距。一次只调整一个变量:先确定纸型,再检查字体文件和就绪信号,最后处理分页。长段落跨页通常可以接受;标题与首段分离或表格行被截断时,应针对该单元添加打印规则。对所有容器一概使用 break-inside: avoid 并不安全,因为比整页还高的内容无法保持完整。
常见问题
为什么 @media print 会改变 PDF,却不改变截图?
page.pdf() 默认按打印媒体类型渲染。截图通常使用屏幕媒体类型,除非测试主动更改。若产品同时要求这两种输出,应分别测试屏幕和打印结果。
为什么 PDF 缺少背景颜色?
API 选项 printBackground 默认是 false。背景承载信息时将它打开,然后检查生成的 PDF,因为阅读器和打印机仍可能执行颜色管理。
为什么中文字符只在 CI 中变成方框?
执行环境可能没有开发机上的相同字体家族或字形覆盖,也可能是在网页字体加载完成前就开始生成 PDF。比较执行环境的字体清单,等待文档字体集合就绪,并渲染生成文件中的页面;不要只凭浏览器版本推断原因。
应该写 page-break-before 还是 break-before?
新 CSS 使用 break-before。page-break-before 仍是旧式别名,但仅改属性名无法解决纸张尺寸冲突、打印样式表缺失或父元素过高的问题。
来源
- Puppeteer:
Page.pdf() - Puppeteer:
PDFOptions - Puppeteer:PDF 生成
- MDN:打印
- MDN:
page-break-before - MDN:
Document.fonts - BotBrowser:字体与配置
实践结论
纸张和页边距只指定一套权威设置,等待应用就绪条件与字体加载完成,并检查实际 PDF,而不只是 DOM。分页失败时先检查打印样式级联、纸张几何尺寸和元素高度;中日韩字形变成方框时,先检查主机字体覆盖与渲染后的文件,再决定是否调整布局。把浏览器版本和字体环境与文件一起记录,后续才能把差异归因到渲染环境,而不是误认为内容变更。
本文属于指南: 浏览器自动化入门:首次运行地图