paged.js 教程:HTML 转 PDF 分页排版与页眉页脚实战

发布时间:2026/10/23 15:08:10
paged.js 教程:HTML 转 PDF 分页排版与页眉页脚实战

简介:面向前端开发者与需要实现网页转PDF、打印分页的工程人员,这份教程围绕 paged.js 展开,重点解决将连续 HTML 内容按 CSS Paged Media 规则切分为规范页面并导出 PDF 的问题,适合具备 JavaScript 与 CSS 基础、希望补齐分页排版能力的读者。资源包共 32401 个文件,压缩后约 40.16MB,主要由 28814 个 png 页面与示意图素材、2918 个 js 脚本,以及 otf、eot、woff、ttf 等网页字体和少量 html、md、json 文档构成,便于对照示例结构、字体配置与脚本组织。内容覆盖 paged.js 的安装引入、基础转换、页面尺寸与边距配置、@page 样式定制、Grid/Flexbox 复杂布局处理,以及 toBlob、toDataURL 输出 PDF、与前端框架集成和模拟器调试等环节。已有 586 人学习,可结合包内素材搭建演示页,理解分页媒体规则,排查分页断裂、样式适配与 PDF 导出问题,形成可复用的前端 PDF 生成思路。

1. 打印分页不该靠缘分:paged.js 到底解决什么问题

后台导出季度巡检报告,第一版直接window.print(),结果二十页的文档里第三章标题孤零零掉在页脚线上,表格被拦腰截断,页码只能靠position: fixed硬凑。后来换成 paged.js 才把这口气顺过来:它是一个用 JavaScript 写的 CSS Paged Media 实现,不负责生成 PDF,只负责把一段 HTML 重新排成一张张带页眉、页脚、页码的页面 DOM,屏幕上看到什么,打印或导出就是什么。我手上这份 paged.js 教程是一份 PDF 资料,从@page规则一路讲到 polyfill 的渲染流程和 handler 扩展,前半部分讲规范,后半部分全是能直接跑的示例。它适合所有要做合同、检测报告、课程讲义、电子书导出的前端,会写 CSS 就能上手,不必去接 LaTeX 或后端排版引擎。

2. 把 paged.js 跑起来:从 polyfill 引入到 Headless 出 PDF

2.1 先搞清楚它接管了浏览器的哪一段

浏览器原生打印只认@page里的size和margin,@top-center这种页边距框、counter(pages)这种总数计数器、target-counter()这种交叉引用,它一概不理。paged.js 补的就是这段空白,所以它叫 polyfill 而不是渲染器。工作过程大致分四步:读取页面里的样式表并解析@page规则;把源内容切成 chunk;把 chunk 逐个塞进页面模板里测量高度;一旦溢出就用 breakToken 回退重新切。这意味着它的分页是「试排加重切」,几百页的文档第一次渲染跑个几秒到十几秒都算正常,不要以为是卡死了。

渲染完成后,原来的内容会被搬进一套固定结构的 DOM:最外层.pagedjs_pages,每一页是一个.pagedjs_page,页内是.pagedjs_sheet,再往下拆成.pagedjs_margin-top-left、.pagedjs_margin-bottom-center这类页边距框,以及.pagedjs_page_content正文区。这个结构得先记住,后面写选择器、做校验、改页码,全靠它定位。

引入方式有三条路,代价各不一样,我一般按内容是不是同步渲染来选:

引入方式适用场景需要注意
<script>引dist/paged.polyfill.js静态页面、服务端模板直出DOMContentLoaded 之后就自动渲染,异步注入的内容会跑空
ESMimport { Previewer } from 'pagedjs'打包进前端工程、需要多次渲染要自己管容器和清理,不能指望自动
PagedConfig.auto = false加手动preview()内容异步、要拿总页数做事多写十几行,但可控性最好

教程里对第二种写法着墨最多,但实际做后台导出,第三种才是常态,因为报告数据基本是接口拿回来再渲染的。

2.2 一个能直接抄的最小页面

先跑通再谈样式。下面这版把源内容、预览容器、样式表分开,避免 paged.js 把预览 DOM 当源内容再排一遍:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <link rel="stylesheet" href="./report.css"> </head> <body> <!-- 源内容:只放原始 HTML,不参与预览 --> <main id="source"> <section class="cover"><h1>季度巡检报告</h1><p>内部资料</p></section> <section> <h2 id="sec-1">一、概述</h2> <p>正文段落……</p> </section> </main> <!-- 预览容器:paged.js 把生成的页面塞这里 --> <div id="preview"></div> <script src="./vendor/pagedjs/paged.polyfill.js"></script> <script> // 关掉自动渲染,等字体和异步数据都就绪再手动触发 window.PagedConfig = { auto: false }; document.fonts.ready.then(async () => { const styles = document.querySelectorAll('link[rel="stylesheet"]'); const flow = await window.PagedPolyfill.preview( document.querySelector('#source'), // 源内容节点 styles, // 样式表节点列表,注意是节点不是 URL document.querySelector('#preview') // 渲染目标容器 ); window.__pagedReady = true; // 给外部脚本/自动化用的完成标记 console.log('总页数', flow.total); }); </script> </body> </html>

preview(content, stylesheets, renderTo)三个参数都得留意。第一个是 DOM 节点,不是选择器字符串;第二个是样式表的 DOM 节点列表,传 URL 字符串它会去 fetch,跨域或路径不对就静默丢样式,页眉页脚全不见;第三个必须是一个独立容器,如果你直接省掉,它会渲染回document.body,源内容被搬走之后二次渲染就抓不到东西了。flow对象里带页码总数和分页后的节点信息,字段名在不同构建里可能叫total也可能要用pages.length,console.log(flow)看一眼就清楚,别硬猜。

document.fonts.ready这一行不是装饰。paged.js 靠实际测量的行盒高度决定断点,中文字体没加载完时用的是后备字体的度量,行高算小了,分页结果就会偏移,表现出来是「刷新一次正常、再一次错位」这种玄学现象。这个坑在第 5 章还会展开。

2.3 用 Headless 浏览器把预览锁成 PDF

浏览器里按 Ctrl+P 打印,等于让浏览器在 paged.js 已经分好的页上再分一次页,出来的白纸能翻倍。生产环境我一般直接走 headless,把「预览」和「出 PDF」合成一步:

# 先起一个静态服务,file:// 下部分资源会被拦 npx serve ./dist -l 8080 # 另开一个终端装依赖 npm i puppeteer
// render.js const puppeteer = require('puppeteer'); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto('http://127.0.0.1:8080/report.html', { waitUntil: 'networkidle0' // 等接口和字体都静下来 }); // 等我们自己的完成标记,别等 .pagedjs_page // 那个类名第一页出来就有了,提前截图会截到半成品 await page.waitForFunction('window.__pagedReady === true', { timeout: 30000 }); await page.pdf({ path: 'report.pdf', preferCSSPageSize: true, // 关键:采用 @page 里写的 size,否则强制 A4 printBackground: true, // 背景色、斑马纹表格靠它 margin: { top: 0, right: 0, bottom: 0, left: 0 } // 别和 @page 的 margin 叠加 }); await browser.close(); })();

preferCSSPageSize一定要开,不然你在 CSS 里写的横版命名页会被压回纵向 A4。margin四个零也有讲究,page.pdf()的页边距和@page的margin是叠加关系,两处都写 20mm 就变成 40mm,正文被挤成窄条。printBackground不开的话,表头底色和分隔线全丢,报告看起来像半成品。

3. @page 与页边距框:页眉、页脚、页码写到该在的位置

3.1 size、margin 与命名页

@page是一切版式的入口,最常用的是尺寸和边距。除了全局规则,还可以定义命名页,用page属性把某个区块挂上去,这是做横版大表格和整页封面的正路:

/* 全局:A4 纵向,上边距留宽一点给页眉 */ @page { size: A4; margin: 22mm 18mm 20mm 18mm; } /* 封面:满版出血,不要页边距 */ @page cover { margin: 0; @top-center { content: none; } @bottom-center { content: none; } } .cover { page: cover; break-after: page; } /* 横版页:宽表格专用 */ @page wide { size: A4 landscape; } .wide-table { page: wide; } /* 首页单独处理,paged.js 也认 :nth(An+B) */ @page :first { @top-center { content: none; } }

size除了A4/A5/letter,也可以写210mm 297mm这种自定义尺寸;需要出血印刷时用size: A4; margin: 0再靠内边距撑内容。命名页的名字是自定义的,和.cover这种类名不绑定,只要page属性和@page名对上即可。break-after: page是给封面收尾用的,不加的话正文会紧贴着封面往下排。:nth(2)这类选择器是 paged.js 的扩展,原生 CSS 里没有,写的时候心里有数,万一以后换渲染器要改。

3.2 十六个页边距框与页眉页脚

@page内可用的页边距框一共十六个:顶部@top-left-corner、@top-left、@top-center、@top-right、@top-right-corner,底部对称的五个,左侧@left-top、@left-middle、@left-bottom,右侧对称的三个。内容用content属性填,可以是字符串、计数器、string()取值,也可以是element()引用页面里的元素。

@page { /* 页眉左侧固定文字,右侧取当前章节标题 */ @top-left { content: "内部资料 · 请勿外传"; font-size: 8pt; color: #888; } @top-right { content: string(chapter); font-size: 9pt; border-bottom: 0.5pt solid #ccc; /* 页眉下划线 */ } /* 页脚居中放页码 */ @bottom-center { content: counter(page) " / " counter(pages); font-size: 9pt; } }

有几个实测出来的边界。一是顶部边距的三个框默认按三等分宽度分配,@top-left里塞一句长中文会往中间挤,和@top-center打架,所以文案要控制长度,或者显式给width。二是页边距框的高度受margin-top限制,字号调大就会被裁掉,页眉想写两行就得把上边距加到 25mm 以上。三是这些框里的样式不继承正文的字体族,字体得在@page里重新声明一遍,否则页脚会用默认衬线体,和正文对不上。

3.3 页码、总页数、章节标题与目录页码

计数器分两类。counter(page)和counter(pages)是页面级的,前者当前页,后者总数,只有 paged.js 渲染后才有值,浏览器原生打印里pages是空的。章节标题靠string-set和string()这一对,在标题元素上采集,在页眉里取用:

h2 { string-set: chapter content(text); /* content() 取整段含标签,content(text) 只要文字 */ } /* 目录页码:a 的 href 指向目标 id,自动补上目标页的页码 */ .toc a::after { content: target-counter(attr(href), page); float: right; }

string-set的采集点是标题元素本身,所以标题如果带编号(<span class="num">三、</span>),content(text)会连编号一起带进去,这通常正是想要的。target-counter做目录页码很省事,前提是目录里的href写成#sec-1这种锚点形式,并且目标元素真的有id;写成相对路径的页面跳转就不生效了,而且不会报错,只是页码空着,这种静默失败最费时间。

页码重编号是另一处需要提前想清楚的地方。封面不想显示页码,用命名页把@bottom-center的content设成none就行;但要让正文从 1 重新开始计数,靠counter-reset在命名页上重置,实测在不同构建里表现不一致,有的版本认,有的版本整篇页码都乱。稳妥做法是留到第 6 章用 handler 处理,或者干脆接受「封面算第 1 页」这个设定,把页码框在封面上隐藏掉,读者感知不到差别。

4. 分页行为的硬控制:break 属性、表格与图片跨页

4.1 break-before / break-after / break-inside

自动分页永远会在最难看的位置断。控制手段就三个属性:break-before、break-after、break-inside,分别管元素前面、后面、内部能不能断。老写法page-break-*也认,但新写法更通用,混着写容易覆盖顺序出问题,统一用新的:

/* 每章另起一页,但标题后面不许断 */ h2 { break-before: page; break-after: avoid; } /* 小标题后面紧跟正文,别让标题孤零零留在页尾 */ h3, h4 { break-after: avoid; } /* 图表说明、引用块整块保留 */ figure, blockquote, pre { break-inside: avoid; } /* 段落允许按行拆,但至少留三行 */ p { orphans: 3; widows: 3; }

break-after: avoid是解决「标题掉页尾」最直接的一招,paged.js 会在排版时把这个标题和后面的内容当作一个整体处理,放不下就一起挪到下一页。orphans和widows在 paged.js 里能生效,但中文长段落的表现不如西文稳定,因为它按行盒拆分,中文字符宽度均等,边界判定更依赖实际测量。我的经验是别指望这两个值精调,真正要保住版式就上break-inside: avoid把整块锁死,代价是可能留下大片空白,得在两者之间权衡。

还有一类反直觉的情况:给一个比整页还高的元素加break-inside: avoid,结果不是它被拆开,而是它直接溢出到页面外面盖住页脚。paged.js 遇到无法安置的元素时不会强行裁剪,所以这个属性只能用在确定小于页内容高度的元素上。

4.2 表格跨页:表头重复与列宽漂移

长表格是最常见的翻车点。表头要在每页重复,靠的是display: table-header-group,paged.js 识别到这个声明后会在每一页的续表里克隆一份thead:

table { width: 100%; border-collapse: collapse; table-layout: fixed; /* 固定列宽,避免每页重新分配 */ } thead { display: table-header-group; } tfoot { display: table-footer-group; } /* 单行不许拆开,否则一个单元格会横跨两页 */ tr { break-inside: avoid; } /* 超宽表格走横版命名页 */ @page wide { size: A4 landscape; } table.wide { page: wide; font-size: 8pt; }

坑在列宽。table-layout: auto时每一页的续表都会根据自己的内容重新分配列宽,第一页三列是 3:4:3,第三页变成 5:3:2,翻页时肉眼可见地跳动。加上table-layout: fixed并配<colgroup>写死百分比,各页就一致了。另一个细节是表头克隆出来的那份thead不带原来的id和事件,如果原表头里有排序按钮或者id被 JS 依赖,续页上会失效,这种问题在导出场景里无所谓,但在交互式预览里要留意。

至于表格要不要整体避免跨页,得看行数:十来行的表直接break-inside: avoid让它整体挪页更整齐;上百行的明细表必须允许拆,否则一页放不下就会溢出。判断标准很简单,估算一下行高乘行数有没有超过页内容高度。

4.3 图片、代码块与长公式的裂页处理

图片裂页的表现是两个半张图分别落在相邻两页,原因是img默认允许在内部断。加break-inside: avoid能解决大部分情况,但要配合max-height,否则一张竖版长图超过页高就变成 4.1 说的溢出:

img { max-width: 100%; max-height: 230mm; /* 略小于 A4 内容区高度,留出图注空间 */ height: auto; break-inside: avoid; } /* 图和它的说明文字绑在一起 */ figure { break-inside: avoid; margin: 0 0 12pt; } /* 图片紧跟标题时不要被拆散 */ h3 + figure { break-before: avoid; }

内容区高度怎么算:A4 高 297mm 减去上下页边距,比如22mm + 20mm,剩 255mm。max-height取这个值的九成左右比较安全,还要给figcaption留位置。代码块同理,pre加break-inside: avoid,但一个超过两页的长代码块加了这个属性就会溢出,这时候得手动在合适的位置插入分页标记,比如在代码中间加一个<div class="page-break"></div>,配一条.page-break { break-after: page; },用人工干预换正确性。

5. 避坑排查:paged.js 落地最常翻车的五处

5.1 渲染时序类:看不见的错位都出在这里

现象一:同一份报告,刷新两次排版结果不一样,有时候最后一页多出一大块空白。原因是 Web 字体异步加载,paged.js 第一次测量用的是后备字体的字符宽度,等真字体到位,行高和折行位置全变了,但分页已经按旧数据切完。解决是把渲染触发挂到document.fonts.ready之后,写法见 2.2;用 headless 导出时再加一道waitUntil: 'networkidle0',双保险。血泪经验是字体文件尽量本地化,别用第三方字体服务,网络抖动会让这个时序问题变得完全不可复现。

现象二:页面一片空白,控制台没有任何报错。原因是数据接口返回后才往 DOM 里插内容,而 polyfill 在DOMContentLoaded阶段就已经渲染完了,它排的是空容器。解决是设window.PagedConfig = { auto: false },等数据落地后再手动调preview(),并且用一个全局标记(就是示例里的__pagedReady)通知外部流程。

现象三:改一次参数,总页数涨一倍,页面越渲染越多。原因是preview()会往renderTo容器里追加页面,重复调用就重复追加,源内容也被搬来搬去。解决有两条:每次渲染前container.innerHTML = ''清空;或者干脆每次new Previewer()一个新实例,不复用。顺手把window.__pagedReady重置为false,否则外部等待逻辑会拿到上一次的旧结果。

5.2 输出与版式类:预览对了不等于打印对

现象四:浏览器里预览 12 页,Ctrl+P 打出来 24 页,每页后面跟一张白纸。原因是.pagedjs_page自己有固定高度,浏览器打印时又按纸张尺寸重新分一次页,两个分页逻辑叠加就多出一张空白。解决是在打印媒体查询里把页间距清零并显式收尾:

@media print { body { margin: 0; background: none; } .pagedjs_pages { display: block; } .pagedjs_page { margin: 0 !important; box-shadow: none; break-after: page; } .pagedjs_page:last-child { break-after: auto; } /* 最后一页不要追空白 */ }

真正省事的做法是绕开打印对话框,直接走 2.3 的 headless 导出,让「分页」这件事只有一个执行者。

现象五:封面显示了页码,正文页码从 5 开始。原因是counter(page)是全局递增的,命名页上的counter-reset在部分构建里不生效。解决分两步:封面用命名页把页边距框的content设成none(页码其实还在,只是不显示);如果确实要求正文从 1 重排,就进第 6 章的 handler,在afterPageLayout里按页序重写页脚文本,比跟 CSS 计数器较劲可靠得多。

另外补一条容易忽略的:源内容里如果有display: none的元素,paged.js 不会渲染它,但它占的 DOM 位置会影响string-set采集,导致页眉取到一个空标题。排查时先querySelector看看取到的文本是不是空的。

6. 用 Handler 接管分页过程:一个页脚校验的小技巧

CSS 能表达的分页规则有上限,超出部分就得靠 handler。paged.js 暴露了一组生命周期钩子,beforeParsing、afterParsing、afterPageLayout、afterRendered这些,注册进去就能在每一页排版完成后做二次加工。最常见的用法是补页码、加防伪编号、给特定页打水印。

// footer-handler.js class FooterHandler extends Handler { // 每排完一页调用一次,pageElement 是当前页的 DOM afterPageLayout(pageElement, page, breakToken) { const box = pageElement.querySelector( '.pagedjs_margin-bottom-center .pagedjs_margin-content' ); if (!box) return; // page.position 是当前页序号,从 1 开始 const total = pageElement.closest('.pagedjs_pages') ?.querySelectorAll('.pagedjs_page').length; // 封面不编号,正文重新从 1 起算 if (page.position === 1) { box.textContent = ''; return; } box.textContent = `第 ${page.position - 1} 页 / 共 ${total - 1} 页`; } } // 注册:手写 Previewer 用 registerHandlers const previewer = new Previewer(); previewer.registerHandlers(FooterHandler); previewer.preview(sourceNode, styles, targetNode); // 如果用的是 polyfill 全局对象,走配置传数组更稳 // window.PagedConfig = { auto: false, handlers: [FooterHandler] };

参数上要盯住两个:page.position是当前页在整篇里的序号,从 1 开始,做「封面不算」的减法就靠它;total我是在容器里数.pagedjs_page的数量拿的,也可以直接用flow.total,但要注意 handler 执行时flow还没 resolve,所以更稳的是在afterRendered里统一回填总页数。这套写法比纯 CSS 计数器啰嗦,好处是逻辑透明,出问题能打断点,不像counter-reset那样不生效也不报错。

验证环节我一般加一个小脚本,把生成的每页页脚文本抓出来对一遍,确认没有重复、没有空值、总数对得上,再走导出。最开始做这类报告导出时,我图省事直接window.print(),结果客户收到的是每页后面跟一张白纸的 PDF,来回解释了两轮才定位到是双重分页。从那以后我每次改完@page或引入新字体,都强制走一遍「headless 导出 → 用 pdf 解析库数页数 → 抽查三页页眉页脚」这条链路,再没让半成品出过门。这份 paged.js 教程里第 3 章到第 5 章的示例文件,我按上面这个顺序重新编过号,拿到手从 2.2 那个最小页面开始跑,比从头读规范快得多。希望帮到你。

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