ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

paged.js实战:用HTML+CSS实现可控分页与PDF打印排版

paged.js实战:用HTML+CSS实现可控分页与PDF打印排版

简介:《pdf:关于paged.js的教程》是一份面向前端开发者的实用资料,聚焦于使用 paged.js 将 HTML 内容高效转换为 PDF 并实现优雅分页。教程覆盖库的定位、安装引入、核心配置、复杂布局适配、导出与下载、Vue/React 等场景集成,以及浏览器调试与优化技巧,适合需要实现客户端打印、动态生成 PDF 报表或合同文档的 JavaScript 开发者。压缩包共 32401 个文件,容量约 40.16MB,以 PNG 截图/演示图片为主,另有约 2918 个 JS 脚本,以及 otf/eot/woff 等字体资源和 HTML/CSS 示例页面;paged.js 本身高度依赖 CSS Paged Media 规范,资源中的字体文件多为排版渲染所需,整体结构便于对照阅读与二次使用。目前已有 585 人浏览学习。借助这套资料,读者能快速掌握从普通网页到分页 PDF 的完整链路,包括如何自定义页面尺寸、边距与打印样式,如何处理 Grid/Flex 等复杂布局,并将成果集成到真实项目中。

1. paged.js 是什么:为什么用 HTML+CSS 做 PDF 而不是直接打印

做 web 页面 PDF 打印的人,多半都遇到过这种场景:页面在浏览器里看着挺正常,一点打印预览,表格被腰斩、标题孤零零落在页尾、页码从 1 开始但第一页是封面。paged.js 就是把这个烂摊子接过去的库——它把 CSS Paged Media 标准实现到浏览器里,让开发者用 CSS 规则控制分页、页边距、页眉页脚和页码,最终输出一份像正式排版过的 PDF 文档。它不是编辑器,不是转换工具,而是一个运行时渲染引擎。适合谁?适合那些需要把动态生成的 HTML 报表、电子合同、课程讲义、产品手册稳定转成 PDF 的从业者。你不需要重新学 LaTeX,也不需要抱着 iText7 写 Java 代码,只要还在写 HTML+CSS,就能把分页这件事从「玄学」变成可调的参数。

2. 跑通最小示例:把一份带样式的 HTML 变成分页 PDF

2.1 引入 paged.js 的两种方式

paged.js 最常见的用法是作为 polyfill 运行在浏览器里。先去 npm 装包,或者直接用 CDN 引入编译好的文件,二选一。我一般推荐用 npm 安装,方便锁定版本,也方便后续接自动化测试。命令如下:

npm install pagedjs --save

装完之后,在你的入口 HTML 里引入 polyfill 脚本。要注意,这里不是普通的<script>引入,因为 paged.js 需要在 DOM 加载完成后接管页面。常见做法是放在 body 末尾:

<script src="/node_modules/pagedjs/dist/paged.polyfill.js"></script>

然后在页面加载完成后触发预览:

window.addEventListener('load', () => { window.PagedPolyfill.preview(); });

preview()是核心方法,它会读取当前文档的所有样式,重新计算分页,并在页面中插入生成的分页符和页边距盒。如果你只是想在浏览器里看效果,这一段就够用了。

如果不用 npm,也可以从 unpkg 或 jsdelivr 拿编译产物,但要注意引入路径里的paged.polyfill.js是浏览器可直接执行的版本,另一个paged.js是模块化版本,不能直接在非 module 场景里用。很多新手在这里踩坑:引了paged.js结果报PagedPolyfill is undefined。

2.2 理解分页机制:CSS Paged Media 与浏览器打印的差别

先弄清 paged.js 到底改了什么。原生浏览器打印也支持@page,但支持度很有限,无法精确控制页眉页脚、页码位置、跨页时的断点规则。px 和 pt 的换算、默认边距、是否打印背景色,每个浏览器都有自己的“小九九”。paged.js 做的事情,是把 CSS Paged Media Level 3 规范里定义的@page、@top-center、@bottom-right这些规则,用 JavaScript 模拟实现,然后生成一个分页后的渲染结果。

这里的关键认知是:paged.js 不是把 HTML“画”到 PDF,而是让浏览器先按打印媒体重新排版,再把排版结果交给打印引擎。所以你在屏幕上看到的预览其实是它动态生成的一层分页视图,每个“页面”是一个独立的区块,背景是白色,四周是模拟的纸张边缘。这跟直接把网页扔进window.print()是两回事——前者每个页面元素都被约束在页面框内,后者只是简单地把流式文档切分。

最小示例到这里已经能跑通了。你可以先写一份简单的 HTML,放一个标题和几段正文,引入 polyfill,调用preview(),然后在浏览器里看效果。第一次跑通,你会看到页面右侧出现滚动条,每一页底部有灰色页码指示——这个页码不是输出到 PDF 里的页码,只是预览时的辅助标记。真正的页码需要自己写 CSS 规则,下一章会说。

3. 页面布局核心:尺寸、边距、页眉页脚与页码设置

3.1 用 @page 规则控制页面盒子

paged.js 对@page的支持比浏览器原生打印完整得多。你可以像下面这样定义页面尺寸、边距和页面背景:

@page { size: A4; margin: 2cm 1.5cm; background: white; }

size除了A4,还支持A3、letter,或是直接用长度单位21cm 29.7cm。注意它接收的是两个值,第一个是宽度,第二个是高度。如果你只写一个值,默认是正方形页面,这经常被忽略。

margin用的单位你可以习惯性写cm或mm,这在打印场景下比px更直观。真正需要注意的是margin和padding的区别:@page里写的是页边距,页面内容会被约束在边距内部;而如果你想给每一页加一个背景色或边框,应该用@page { background: ... }吗?不行,@page的background只在部分浏览器生效。更稳妥的做法是把背景画在html或body上,paged.js 会把背景铺到每个页面。

如果你需要首页不同,可以用伪页面:

@page :first { margin-top: 3cm; } @page :left { margin-left: 2.5cm; margin-right: 1.5cm; } @page :right { margin-left: 1.5cm; margin-right: 2.5cm; }

:left和:right对应书籍的双页排版,奇偶页边距镜像。这个在打印电子书、双面文档时特别有用。但要注意,paged.js 对:left/:right的判定是基于最终分页结果的页码,如果你在预览前还没分页,这些规则不会生效。

页面盒子还有个容易被忽略的属性:bleed。这是印刷用语,指裁切出血线。如果你在生成的是需要交付印刷厂的 PDF,需要加上:

@page { size: A4; bleed: 3mm; }

paged.js 会在页面四周留出 3mm 的出血,但前提是你的内容背景已经延伸到那个区域。这个细节不做印刷的人可能一辈子用不上,但遇到一次就知道有多救命。

3.2 页眉页脚与页码:counter(page) 和 position: running()

页眉页脚是 paged.js 最值得吹的功能。原生浏览器打印想做“第 X 页 / 共 Y 页”基本要靠浏览器扩展,而 paged.js 里只是几行 CSS:

@page { @bottom-center { content: counter(page) " / " counter(pages); font-size: 9pt; color: #666; } @top-left { content: "内部资料"; font-size: 8pt; } }

这里@bottom-center是页面边距盒子的名称,它定义在@page规则内部。paged.js 支持顶部、底部、左侧、右侧各三个位置:top-left、top-center、top-right、bottom-left、bottom-center、bottom-right,再加上左右边距中的left-top、left-middle、left-bottom之类。常用的是顶部和底部那六个。

counter(page)是当前页码,counter(pages)是总页数。这两个计数器在分页完成后才会被正确填充,所以不要在普通流式内容的 CSS 里引用它们,只能在边距盒子内部用。

如果页眉里想显示当前章节标题,paged.js 提供了position: running()机制。先给章节标题定一个名字,然后在页眉里引用:

h1 { string-set: chapter content(text); } @page { @top-center { content: string(chapter); } }

这里的string-set属性会把h1的文本内容绑定到名为chapter的字符串,之后在页面边距盒子里用string(chapter)读取。实际效果是:每进入一个新章节,页眉自动变成当前章节标题。这个特性做技术文档、合同模板时非常实用,比如每页顶部显示当前合同条款的小节名。

需要注意,string-set不支持所有 CSS 取值函数,想取元素的属性值、连接多个文本,语法上有兼容坑。最稳妥的方式是取content(text),它就是元素的纯文本内容。

页码从哪开始,也是高频需求。封面不想显示页码,或者正文从第 1 页开始。paged.js 里可以通过counter-reset结合命名页来做到:

.cover { page: cover; } @page cover { @bottom-center { content: none; } } .content { page: main; } @page main { @bottom-center { content: counter(page); } }

但这里有个坑:page: cover只是给元素指定了页面类型,并不会让页码归零。要重置页码,需要额外写:

@page main { counter-reset: page 0; }

不过这个重置方式在不同的 paged.js 版本里行为不一致,有人用了有效,有人发现页码从 1 继续。我一般更建议在封面的最后一个元素上手动设置break-after: page,然后用counter-reset在内容区第一页重置。实践证明越是复杂的页码逻辑,越需要把每一页的页面类型定义清楚,而不是依赖隐式的连续分页。

4. 内容分页控制:标题、表格、图片怎么避免被拦腰截断

4.1 break-before / break-after / break-inside 的选型

分页控制的核心就是三个属性:break-before、break-after、break-inside。它们分别控制元素之前、之后、内部是否允许分页。要注意,page-break-before那套老属性在 paged.js 里也能用,但新标准推荐用break-*,两者混写时以新属性为准。

实际项目里最常用的几个场景:

h1, h2 { break-after: avoid; } table, pre, figure { break-inside: avoid; } .new-page { break-before: page; }

break-after: avoid的意思是:标题后面不要紧跟分页符,保证标题和它后面的第一段内容在同一页。这个规则能直接消灭“标题孤行”——标题在一页末尾,正文在下一页开头。很多新手会写成page-break-after: avoid,但其实老规范里page-break-after的avoid值支持度很差,换成break-after更可靠。

break-inside: avoid则用于防止表格、代码块、图片被切到两页。但要注意,这个属性不是万能的:当元素高度超过一整页时,avoid无法生效,浏览器必须允许它断开,否则内容会被截断丢失。所以遇到超长表格,你需要反过来处理,下一节细说。

如果想要“这个标题必须从新页开始”,用break-before: page。注意 paged.js 里这个值是大小写敏感的,写了Page或PAGE都无效。

分组控制也有妙用。当你有一组卡片或条目,希望保证至少两条在一起时,可以这样:

.item { break-inside: avoid; } .item-container { break-before: avoid; }

break-before: avoid表示本元素尽量不要作为一页的开头。这样两条.item出现在同一页的概率更高。这里要理解break-before和break-after是作用于两个元素之间边界的,它们的avoid值会让分页算法尽量把断点移开这个边界。

4.2 表格跨页时怎么保留表头与重复列

表格是 PDF 生成里最让人头疼的部分。短表格用break-inside: avoid直接锁住,但长表格一旦超过一页,必须允许分割。分割时有两个刚需:表头要重复,行不能从中间劈开。

paged.js 支持thead跨页重复显示,但你需要显式地把border和background写到th上,而不是写到table上,因为多数浏览器对table级背景的跨页重复支持很弱。示例:

table { width: 100%; border-collapse: collapse; } th { background: #f0f0f0; border-bottom: 1px solid #999; } tr { break-inside: avoid; }

tr { break-inside: avoid; }能保证一行不被切成两半压在页缝上。这是必须写的,否则打印出来会看到某行文字上半截在页底、下半截在页顶。

对于有多列且想要每页左侧重复项目名称的表格,paged.js 本身没直接提供“重复列”的功能,但可以用 CSS 伪元素模拟:将第一列单元格的内容设置为position: running()绑定到一个字符串,然后在页边距里显示。不过这东西实现起来很绕,我一般更推荐在导出前改变表格结构——把重复列的数据放到thead里,或者对表格做横向拆分。实际业务里,横向超宽的表格更适合转成纵向堆叠的卡片式排版,早转换早省心。

图片跨页的问题相对简单。一张大图如果落在页缝附近,会有两种表现:被整体挪到下一页,留下大面积空白;或者被裁掉一部分。控制方式:

figure { break-inside: avoid; max-height: 100%; overflow: hidden; }

max-height: 100%很关键,它限制图片容器高度不超过当前页内容区。但要注意,100%是相对于父元素高度,如果你的图片在正文里,父元素高度是无限的,这个百分比会失效。更稳的做法是给图片设一个固定的最大高度,比如针 A4 内容区高度 24.7cm,那就写max-height: 24cm,留一点余量。别写100%,在分页上下文里它经常是无效值。

5. paged.js 实战避坑:5 个高频问题与排查方法

5.1 现象:打印预览里样式全部丢失,只剩裸文本

原因:最常见是把@media print里的样式误写成了@media screen,或者引入样式表时没有让 paged.js 在样式加载完成后才初始化。paged.js 是在当前文档样式基础上计算分页的,如果preview()调用时样式表还没加载完,它就会拿一份无样式 DOM 去分页,生成的结果自然没有排版。

解决:在window.onload里调用preview(),确保所有样式表和图片资源加载完毕。如果依然丢样式,打开 DevTools 看document.styleSheets里有没有你的样式规则,重点检查 CSS 文件是否因为跨域问题被浏览器拦截。多数情况是本地调试时用file://协议打开页面,导致样式表跨域失败,换成http://localhost就正常了。

5.2 现象:页码不显示或从第 0 页开始

原因:.preview()默认在页面内部生成分页视图,但counter(page)这个计数器是在分页渲染时才建立的,如果你把页码写到了普通内容元素的content里,而不是@page内部的边距盒子,就不会生效。还有种情况是你在@bottom-center里写了counter(page),但打印机的默认边距覆盖了页边距盒子,导致页码被裁掉。

解决:检查你的@page边距是否足够容纳页脚。比如margin-bottom: 1cm而页脚内容高度0.5cm,应该没问题;但如果margin-bottom: 0,页脚就会跟正文重叠或者消失。把边距设大一些,比如margin: 2cm,先排查空间问题。页码从 0 开始则是因为某些浏览器 CSS 计数器默认从 0 计数,paged.js 做了兼容,但如果你手动写过counter-reset: page 0,就会出现第一页页码是 0。删掉多余的重置,或者改成counter-reset: page 1(在非标准下有效,测试你自己的版本)。

5.3 现象:页面大小总差 1~2px,导致出现空白页

原因:@page { size: A4; }定义了页面尺寸,但浏览器打印时默认缩放不是 100%,或者系统显示缩放比例不是 100%,导致 paged.js 计算出的页面高度与打印机实际渲染高度不一致,结果内容溢出到多出的空白页。

解决:在调用preview()前,强制设置文档的视口缩放。常见做法是:

PagedPolyfill.preview(() => { document.documentElement.style.zoom = '100%'; });

如果不行,检查是否有transform: scale()在 body 或某个容器上。另一个原因是margin使用了小数单位,比如1.2cm,在像素换算时四舍五入导致误差。把 margin 取整到mm级别,比如12mm,能减少这类问题。这个坑在 Windows 高分屏下尤其明显,血泪经验:宁可全部用mm也不用cm,因为cm与 px 的换算系数在不同系统里有差异。

5.4 现象:动态内容渲染完但分页没更新

原因:你的表格或数据是 AJAX 异步加载的,内容插入 DOM 后没有重新触发分页。preview()只执行一次,不会观察 DOM 变化。

解决:在数据更新完成后手动重新调用preview():

async function updateData(newHtml) { document.getElementById('content').innerHTML = newHtml; await window.PagedPolyfill.preview(); console.log('分页完成'); }

如果频繁更新,先销毁老的分页再重建,避免内存泄漏:

if (window.PagedPolyfill) { window.PagedPolyfill.afterPreview((flowed) => { ... }); }

更稳妥的做法是把动态渲染和分页完全分离:先渲染内容到隐藏的 iframe,等 iframe 加载完成后,再把 iframe 的 body 通过document.adoptedStyleSheets或直接移动到主文档,最后调一次preview()。paged.js 对动态插入的样式表支持不够好,很多奇怪的错位都源于运行时加 style 标签但没触发重新计算。

5.5 现象:中文字体导致页面溢出

原因:浏览器对中文字符的换行行为与英文不同,paged.js 在计算行高和断行时,如果碰到长中文词(比如连续数字、URL),不会自动断行,导致内容宽度超过页面容器,撑破布局,下一页出现多余溢位。

解决:给全局样式加:

body { word-break: break-all; line-height: 1.6; }

用word-break: break-all强制任意字符间可断开,适合中文和英文混排。如果你担心英文单词被拆得太碎,可以用overflow-wrap: break-word替代,但对防止溢出中文来说,break-all更有效。另外,中文字体的行高设定也很关键,paged.js 在计算“剩余空间”时按 line-box 高度计算,如果你的 font-size 和 line-height 比例过大,页面底部会多出一条放不下的行,预留边距时要把行高算进去。经验值:内容区高度减去最后一行行高,才是真正能安全放入文字的高度。

6. 进阶:把 paged.js 接进自动化 PDF 生成管线

6.1 用 Puppeteer 无头浏览器渲染并导出 PDF

在浏览器里调试好分页效果后,最终要输出 PDF 文件,通常的做法是交给 Puppeteer(或 Playwright)无头浏览器加载页面,调用page.pdf()落地。这里有个关键参数:必须禁用preferCSSPageSize吗?恰恰相反,要让 Puppeteer 使用你的@page设定,需要这样传参:

const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('http://localhost:8080/report.html', { waitUntil: 'networkidle0' }); await page.evaluate(async () => { await window.PagedPolyfill.preview(); }); await page.pdf({ path: 'output.pdf', format: 'A4', printBackground: true, preferCSSPageSize: true }); await browser.close(); })();

preferCSSPageSize: true让 Puppeteer 优先采用@page里定义的尺寸,否则会以format参数为准。两个地方都写 A4 最保险。printBackground: true是必须的,否则表格表头背景色、页面背景全消失。这段代码里有两个等待:goto的networkidle0等所有资源加载,preview()等分页计算完成。少一个都会出现空页或不完整。

6.2 校验分页结果:如何快速检查页面边界与元素裁剪

自动化生成的 PDF 不能只靠肉眼一个个翻页检查。我习惯在调试阶段给页面加辅助标尺:

@media print { .pagedjs_page { outline: 1px dashed #ccc; } }

paged.js 会给每个分出的页面加.pagedjs_page类名,利用这个类你可以写脚本来检查元素是否越界。例如在 Puppeteer 里:

const overflow = await page.evaluate(() => { const pages = document.querySelectorAll('.pagedjs_page'); let results = []; pages.forEach((pg, i) => { const rect = pg.getBoundingClientRect(); const children = pg.querySelectorAll('*'); children.forEach(el => { const r = el.getBoundingClientRect(); if (r.bottom > rect.bottom + 1 || r.right > rect.right + 1) { results.push(`第${i+1}页: ${el.tagName}.${el.className} 溢出`); } }); }); return results; }); if (overflow.length) console.log(overflow);

这个脚本能精准输出“第几页的哪个元素溢出”。它抓的是几何越界,但要注意,某些伪元素、绝对定位的装饰性元素可能故意溢出,你需要排除它们。我一般在生产前跑一遍,看到溢出列表,就按图索骥去修 CSS。

还有一个验证技巧:生成 PDF 后,用pdf-lib或其他库读取页数、页宽高,确认总页数与你的预期一致,再抽查第二、最后几页的文字是否出现在正确位置。这一步能拦截大量因字体加载延迟导致的分页错乱——字体在 preview() 之后才加载完,浏览器重新排版,但 paged.js 没收到通知,最终 PDF 和预览不一致。解决办法是在调用preview()前用document.fonts.ready等待字体就绪:

await document.fonts.ready; await window.PagedPolyfill.preview();

这个细节救过我很多次。现在我做 PDF 生成,已经养成了固定习惯:先本地起服务,再用 Puppeteer 跑一份带调试标记的 PDF,检查溢出脚本,最后才上生产。paged.js 把分页从玄学变成了可调试的 CSS 问题,但前提是你要像对待普通 CSS 一样对待分页规则——哪里不对,就去 DevTools 里看那个元素的几何盒子,而不是盲目改参数。这套流程跑顺之后,再做报表、合同、讲义的 PDF 输出,就只剩下内容填充的体力活了。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进