paged.js分页引擎实战:从CSS Paged Media到PDF导出 简介《关于paged.js的教程》是一份面向前端开发者的JavaScript库学习资料系统讲解paged.js在将HTML内容转换为PDF场景中的实际用法内容包括安装引入、基础分页示例、页面尺寸与页边距设置、复杂网格与弹性布局适配、生成结果导出以及同常用前端框架的集成还涉及利用浏览器模拟器进行预览调试。教程从浅入深展开适合需要在用户端生成高质量PDF文档的初中级开发者查阅与实践。压缩包内共有32401个文件整体大小约40.16MB。其中两万八千余张PNG图片用于展示分页效果或文档截图近三千个JS文件是库源码与示例逻辑大量OTF、WOFF、TTF字体文件用于保障PDF排版同时还有少量HTML、SVG与CSS文件组成示例页面结构清晰便于按需检索。目前已有585人浏览学习。通过这份资料读者可以打通从环境搭建、页面设计到最终输出PDF的完整流程掌握分页规则、样式自定义及常见问题调试方法为后续在真实项目中应用paged.js打下扎实基础。1. paged.js 到底是什么给浏览器装上一个能读懂 CSS 分页规范的内核做前端的人大概都遇到过这种需求用户要在网页里编辑一份合同、一本题库或者一份几十页的标书点一下“导出 PDF”就要看见排版好的成册文档。直接调 window.print() 常常被打印预览和屏幕样式双重打脸样式靠 DevTools 调半天打印出来还是章节断页、表格被拦腰切成两半、页码对不上。paged.js 就是来终结这种玄学循环的它是一个纯 JavaScript 实现的 CSS Paged Media 引擎在浏览器内部把长文档按页切开支持 page 页面尺寸、页眉页脚、页码计数、目录回链这些印刷排版能力。适合电子合同、报告生成、题库排版这类“浏览器里排版、输出 PDF”的场景。这份教程就是围绕它讲清楚原理和落地配置的。2. 分页到底怎么发生paged.js 的工作流程与三个核心 API2.1 两种接入模式Polyfill 自动接管与 Previewer 手动控制拿到 paged.js 教程包最先要分清的就是两种工作模式这决定了你的页面是“加一行脚本就能跑”还是“要写 JS 手动调度”。Polyfill 模式是最省事的接入方式。你在 HTML head 里引入 paged.polyfill.js它会在 DOMContentLoaded 之后自动扫描文档把 CSS 里的 page、top-center、counter(page) 这些规则翻译成浏览器能执行的分页操作然后把原来一整块 body 内容切成多个页面盒子。每个盒子对应一张纸视觉上从“滚动长文档”变成“一张张翻页”。这就是 paged.js 名字的由来页面被切分、装订像一本真的书。Previewer 模式则更偏向开发调试和高度定制。它把分页过程拆成 Chunker 切块器、Renderer 渲染器、Previewer 预览器三个类开发者可以手动控制何时切页、何时渲染、何时重新布局。教程里一般会把这一步封装成调试入口方便在浏览器控制台里观察每个页面的盒子结构。我只在需要排查“某一页多了一行”这种问题时才打开它平时直接用 Polyfill。两种模式不需要同时启用。我一般先把 Polyfill 跑通业务再去源码里翻 Previewer。如果你的目标只是做一套导出 PDF 的流水线Polyfill 就够不用过度设计。下面是一个最基础的引入示例!DOCTYPE html html langzh-CN head meta charsetUTF-8 title示例合同导出/title link relstylesheet hrefcss/paged.css script srcdist/paged.polyfill.js/script /head body section classcover h1项目服务合同/h1 p合同编号ABC-2024-001/p /section section classcontent h2第一条 服务内容/h2 p正文内容……/p /section /body /html逻辑说明script 放在 head 且位于样式之后paged.js 会注册事件在 DOM 加载完成后自动接管布局link 里的 paged.css 存放 page 等分页规则。这样页面打开时就已经是分页后的形态不需要额外写初始化 JS。参数说明src 指向构建产物在 npm 工程里通常是node_modules/pagedjs/dist/paged.polyfill.js也可以把这个文件复制到自己的静态目录。文件名带polyfill是刻意区分的它表示这个版本会自动补齐浏览器缺失的分页能力不需要手动 new 实例。再看一个区别点Polyfill 模式不兼容在 body 里再来一次window.onload包裹的 DOM 重排操作因为 paged.js 完成切页后原有节点会被移进新的页面容器旧引用失效。这个坑第 4 章专门讲。2.2 页面尺寸与命名页面page 规则怎么被解析page 是 CSS Paged Media 规范里最核心的规则。普通浏览器对它的支持其实很有限比如 Chrome 只认 size 和 margin而 paged.js 有一套自己的解析器会把页面尺寸、边距、出血、裁剪标记都算进页面盒模型里。常见做法是先在样式顶层声明一份全局页面配置page { size: A4; margin: 24mm 20mm 28mm 20mm; bleed: 3mm; }逻辑说明size 决定页面物理尺寸A4 是国际标准打印尺寸也可以写成 210mm 297mm 更精确margin 的四值按上、右、下、左排列决定内容安全区bleed 是印刷里的出血线PDF 转印刷厂时预留裁切空间。参数说明如果不需要印刷bleed 可以去掉。margin 不要设成 0否则正文顶着纸边页眉页脚没位置放。留白多少按业务来我习惯上 24mm 下 28mm给页码留足够空间。page 还支持“命名页面”这是解决“封面不要页码、正文从第 1 页开始”这类需求的关键page cover { margin: 0; } page content { margin: 24mm 20mm 28mm 20mm; } .cover { page: cover; } .content { page: content; }逻辑说明CSS 属性page把一个元素绑定到某个命名页面paged.js 遇到元素切换时自动换用对应页面规则。封面页设 margin: 0正文页设标准边距互不干扰。参数说明命名页面的名字可以随意取但要全局唯一并且与 page 后的名字完全一致。注意这个page属性是标准 CSS 属性不是 paged.js 私有的 JS 配置所以写在样式表里即可。我第一次用这个特性时翻过车把 page 属性写在元素 style 上结果 paged.js 不认。命名页面只能通过 CSS 规则指定内联样式不生效——倒不是 paged.js 的 bug而是规范里内联样式不能触发页面类型切换。2.3 页眉页脚与页码用 Margin Box 排版页眉页脚是 paged.js 比裸浏览器打印强得多的能力。浏览器通常只能顶格加个文件名和时间paged.js 则可以用 Margin Box 在页面边距区域插入内容CSS 的 content 属性里可以放计数器也可以放固定文案。page content { top-center { content: 项目服务合同; font-size: 9pt; color: #666; } bottom-right { content: 第 counter(page) 页 / 共 counter(pages) 页; font-size: 9pt; } }逻辑说明top-center 是页面顶边距区域的正中位置bottom-right 是底边距区域的右缘。content 里可以直接拼字符串和计数器counter(page) 是当前页码counter(pages) 是文档总页数。参数说明font-size 和 color 按打印习惯设置一般正文 10-11pt页眉页脚 8.5-9.5pt太大会显得喧宾夺主。页眉页脚只在对应的命名页面里出现——如果正文内容没有设置 page: content它默认落到全局 page所以页眉页脚写在哪类 page 下就决定它出现在哪些页。提示counter(pages) 只有在 paged.js 完成整篇文档分页之后才能算出准确值。如果你在页面刚加载时就读取这个值会得到一个临时计数这在动态内容下很容易引发“页码总数对不上”的迷惑行为。这一块是整份教程的基础把命名页面、Margin Box 和计数器组合起来基本能覆盖八成业务需求。接下来的实战章节我直接带你做一个带目录的完整文档。3. 实战把一篇带目录和章节换页的长文档跑成分页 PDF3.1 初始化工程并引入 paged.js光讲规则容易飘我们直接做一个模拟项目X一个 30 页左右的标书包含封面、目录、四章正文。目标是在浏览器里打开 HTML得到一份排版正确、目录带页码、每章从新页开始的 PDF。先建目录mkdir paged-demo cd paged-demo npm init -y npm install pagedjs逻辑说明npm install 会从 registry 拉取 pagedjs 包里面有 dist/paged.polyfill.js 和源码。如果你的运行环境不允许 Node也可以去官网下载构建产物效果一样。参数说明npm init -y 是为了快速生成 package.jsonpagedjs 只作为前端静态资源使用所以不需要参与打包构建直接引用 dist 下的脚本即可。然后是 HTML!DOCTYPE html html langzh-CN head meta charsetUTF-8 title某跨平台系统标书/title link relstylesheet hrefstyle.css script srcnode_modules/pagedjs/dist/paged.polyfill.js/script /head body div classcover h1某跨平台系统建设方案/h1 p classmeta版本V2.0/p /div nav classtoc h1目录/h1 ul lia href#ch1第一章 项目背景/a/li lia href#ch2第二章 系统架构/a/li lia href#ch3第三章 实施计划/a/li lia href#ch4第四章 运维保障/a/li /ul /nav section idch1 classchapter h1第一章 项目背景/h1 p……/p /section !-- 后续章节结构相同 -- /body /html逻辑说明目录使用a href#ch1这种锚点链接paged.js 可以借助 target-counter 把链接目标所在的页码算出来写进目录项后面这就是 PDF 目录页码的来源。section 用 id 作为锚点和目录里的 href 一一对应。参数说明锚点 id 必须唯一链接的 href 以 # 开头不能写绝对路径。如果你后续要生成可点击跳转的 PDF 目录href 的锚点机制是标准选择。3.2 用 target-counter 给目录加上真实页码目录页最头疼的就是页码。手写容易飘自动生成又怕排版引擎不支持。paged.js 实现了 CSS Generated Content for Paged Media 里的 target-counter可以在目录项后面自动显示目标的页码。.toc a::after { content: leader(.) target-counter(attr(href), page); }逻辑说明attr(href) 从 a 标签里取出锚点值如 #ch1target-counter 查找该目标在第几页并把页码返回给伪元素leader(.) 生成一长串圆点引导线让页码右对齐。这一行 CSS 是目录页码的核心普通浏览器不认这个写法只有 paged.js 这类引擎才解析。参数说明如果想右对齐页码可以配合.toc a { display: block; }再结合文本对齐。leader() 的字符不仅能写点还能写短横线但印刷习惯用点。再配合目录排版.toc { page: content; break-after: page; } .toc li { list-style: none; margin-bottom: 4pt; } .toc a { text-decoration: none; color: inherit; }逻辑说明break-after: page 让目录结束后强制换页下一页开始第一章目录项去掉下划线保持打印一致性。参数说明list-style: none 去掉默认序号因为标书目录通常自己带章节号。如果你要保住序号可以写 counter(章节号)。这里为了简洁我直接写在列表标记里。写完这步打开页面就能看到目录项末尾出现数字 2、3、4 这样的页码而且对应关系基本准确。这里有个前提目录里的链接指向的元素确实存在 id否则 target-counter 找不到目标内容为空这是常见错误。3.3 章节强制换页与元素避让规则长文档最怕表格被切断、标题孤零零盖在页面底部。paged.js 对 CSS 分页属性的支持比较完整css 里常用的是 break-before、break-inside 和 widows/orphans。.chapter { break-before: page; } table, .keep-together { break-inside: avoid; } h1, h2 { break-after: avoid; page-break-after: avoid; } p { widows: 2; orphans: 2; }逻辑说明break-before: page 让每个 .chapter 章节都从新页开始避免上一章末尾空半页break-inside: avoid 让表格和指定容器尽量完整出现在一页里不被拦腰切断break-after: avoid 要求标题后紧跟正文防止标题孤悬页尾。widows/orphans 控制段落在页首/页尾最少保留的行数2 行是印刷常用下限。参数说明旧项目里常见的 page-break-before 这类属性 paged.js 也兼容但新代码建议写标准属性 break-before。两类都写上可以兼顾老浏览器注意标准属性放前面兼容属性放后面。关于表格避让血泪经验是大表格用 break-inside: avoid 不一定每次都奏效因为如果表格本身比一页高引擎只能切断。正确做法是先估算表格行高超过一页的表格拆成两个子表或者允许表头重复。paged.js 里可以在 thead 元素上设置重复表头这个我在避坑章节再展开。到这一步打开浏览器你应该能数出文档被切成多少页每章从新页开始目录有页码页脚有总页码。接下来把输出 PDF 这条路走完然后集中讲我在实际项目里踩过的坑。4. 避坑指南五个让我返工到凌晨的分页问题4.1 坑一分页后事件绑定全部失效现象页面在浏览器里正常展示分页也正确但点击正文里的一个按钮控制台报错说节点不存在。原因paged.js 的 Polyfill 模式在完成分页后会把原有 DOM 节点移动到新的页面容器中。如果你在脚本里保存了某个子节点的引用分页过程会把这个节点从原位置摘走先前的引用变成游离节点绑定的事件自然不触发。解决事件委托全局比如 document.addEventListener(click)再用 closest 判断目标或者把交互元素放在不需要参与分页的 iframe 层导出时只打印主文档。我一般会写一个全局事件兜底把按钮的 handler 委托到 document这样即使节点位置变动也能命中。提示如果页面里有动态图表或地图最好等 paged.js 完成分页后再渲染否则切页会把新增节点漏掉。4.2 坑二页码从 0 开始或者目录对不上现象目录里第一章标的是 0 页实际翻到第一章明明是第 1 页或者目录显示 3 页正文里页码是第 4 页。原因counter(page) 的初始值在 paged.js 里可以在 page 规则中重置但如果你在 CSS 开头写了 counter-reset: page 0就会把所有页码起点改成 0另外封面和目录页没有计入 counter(pages) 时也会出现偏移。解决不要在全局声明 counter-reset: page 0。封面页如果需要不被计数可以用命名页面把封面和目录排除再在正文第一个元素上设置 counter-reset: page 1。目录页码对不上时先检查是不是 page 规则作用域问题再检查 target-counter 的锚点是否解析。4.3 坑三中文字体在 PDF 里发虚或变方框现象HTML 里显示正常的微软雅黑导出 PDF 后笔画发虚某些特殊符号直接变成方框。原因paged.js 本身不处理字体文件它只是排版引擎。字体渲染依赖浏览器和系统字库没有把字体文件嵌入 PDF 时打印引擎会降级到系统默认字体中文就可能错位。解决在 CSS 里用 font-face 声明本地字体文件并且在 page 规则里显式指定 font-family导出 PDF 时由浏览器负责嵌入。中文字体文件比较大我只嵌入用到的字重通常一个 400 一个 700 就够。4.4 坑四分页后出现整页空白现象某章从第 7 页开始但第 6 页全是空白肉眼看不到任何内容。原因多半是 break-before: page 和某个容器自身的 margin 叠加了。比如 .chapter 有 20mm 的上外边距同时又强制分页分页引擎在计算页面高度时把空白归到了上一页就会留下一个透明页。解决检查相邻元素是否都有 margin 或 padding把强制分页的元素加上 margin-top: 0或者用break-before: page的元素内边距替代外边距避免高度溢出。这种问题单看样式表很难发现打开控制台选中空白页的承载元素看盒模型就能定位。4.5 坑五大表格跨页被斩断且表头不重复现象一个 20 行的表格跨了两页第二页里的行没有表头读者完全看不出列含义。原因默认分页引擎允许块级元素跨页切断表头在页首不会自动重复需要显式声明。解决使用 paged.js 的表头重复特性把表头放在 thead 里再配合break-inside: auto引擎在跨页时会自动复制 thead。更稳的方案是拆表按每页可容纳行数拆成多个 table封装一个函数来分行。这五个坑是我在多次导出一堆标书和合同后攒下来的。整体原则是paged.js 只是分页引擎不是 PDF 生成器字体、图片、DOM 交互都还得按浏览器的规则来。5. 批量导出 PDFpaged.js 与无头浏览器配合5.1 为什么手工打印做不了批量流水线业务里生成一份合同没问题但到了月底要把几十份不同模板的文档同时导出逐个人工点打印就歇菜了。所以服务端或者客户端得有一个自动化的“打开页面 等待分页 输出 PDF”通道。常见做法是用无头浏览器。前端生成 HTML无头浏览器加载并执行 paged.js 分页然后调用打印 API 输出 PDF。我一般选 Chromium 系因为打印表现和用户本地 Chrome 基本一致CSS 兼容性风险最小。5.2 一个可复用的导出脚本我们在项目里加一个脚本渲染入口用无头浏览器加载本地 HTML 文件const puppeteer require(puppeteer); async function exportPdf(htmlPath, outputPath) { const browser await puppeteer.launch({ headless: true, args: [--no-sandbox, --disable-setuid-sandbox] }); const page await browser.newPage(); await page.goto(file:// htmlPath, { waitUntil: networkidle0 }); await page.emulateMediaType(print); await page.pdf({ path: outputPath, format: A4, printBackground: true, preferCSSPageSize: true, margin: { top: 0, right: 0, bottom: 0, left: 0 } }); await browser.close(); } exportPdf(build/proposal.html, output/proposal.pdf);逻辑说明page.goto 加载 HTML 并等待所有资源字体、图片、脚本加载完networkidle0 表示网络空闲后再往下走emulateMediaType(print) 把渲染切到打印介质paged.js 的分页规则会以打印样式为准page.pdf 是最终输出。参数说明format: A4 是后备尺寸如果 CSS 里的 page size 已经生效preferCSSPageSize: true 会优先采用 CSS 尺寸。margin 全部设 0 是因为 page 规则里已经包含了 margin浏览器侧的 margin 再叠加会导致双重留白。如果页面里用了懒加载图片waitUntil 可以改成 [networkidle0, domcontentloaded] 组合。5.3 参数调整与实际运行时的三个细节第一无头浏览器模式。很多线上环境需要 --no-sandbox否则启动报错如果你的部署环境有用户权限限制还得加 --disable-dev-shm-usage 防止共享内存不足。第二字体预加载。导入 PDF 的页面如果用了远程字体需要保证字体文件在 networkidle0 之前加载完否则截图中文字体还是方框。我的习惯是在样式表里把字体用 font-face 规则前置并在 HTML 中预加载。第三文件路径不能有中文空格问题。file:// 协议对中文路径支持一般建议先用 URL 编码转换或者在 Node 里 pathToFileURL 生成正确的 URL。我经常在处理下载文件名时踩到这类路径坑。const { pathToFileURL } require(url); await page.goto(pathToFileURL(htmlPath).href, { waitUntil: networkidle0 });逻辑说明pathToFileURL 会把含空格和中文字符的绝对路径转成合法的 file URL避免无头浏览器解析路径时报错。 参数说明注意返回的 href 是一个完整的 URL 字符串不是 decode 过的不带协议路径。用这套脚本我一周大概能跑几百份文档稳定性不错。如果你只是单份导出也可以直接让用户开 Chrome 手动打印批量场景才值得上这套。如果发现 PDF 里出现了重复空白页多半是浏览器原生分页与 paged.js 自定义分页叠加检查 page margin 和 body 样式即可。6. 进阶技巧动态图表与自定义页眉的最后一公里6.1 让 Canvas 图表进 PDF 而不糊业务文档往往不止文字还有折线图、柱状图。Canvas 绘制的图表在分页后经常是空白或模糊的因为打印引擎对 Canvas 的捕捉不稳定。常见做法是先把图表转成图片再放入文档。function chartToImage(canvas) { return canvas.toDataURL(image/png, 2.0); } const img document.createElement(img); img.src chartToImage(document.querySelector(#chart)); img.style.width 160mm; document.querySelector(#chart).replaceWith(img);逻辑说明toDataURL 把 Canvas 内容导出为 PNG 的 base64 字符串然后创建一个 img 节点替换原 Canvas。paged.js 在分页前处理的是 img打印时天然支持不会出现空白。 参数说明toDataURL 的第二个参数是像素清晰度2.0 代表两倍分辨率适合在 A4 上打印。宽度用 mm 单位避免受屏幕像素密度影响。6.2 在页眉显示当前章节标题named stringspaged.js 还实现了一个列印场景的杀手级功能把页面里某个元素的内容提取出来动态放进页眉。比如每章标题不同页眉也跟着变。page content { top-left { content: string(chapter-title); } } .chapter h1 { string-set: chapter-title content(text); }逻辑说明string-set 把 h1 的文本内容提取到名为 chapter-title 的字符串变量里top-left 的 content 引用这个变量。每翻到新的一章页眉标题自动替换成当前章的标题。参数说明content(text) 表示取纯文本如果需要加前后缀比如“第 X 章”可以在 content 里拼content: 第 content(text) 的形式实际上语法是 string-set: chapter-title 第 content(text); 更严谨。但要小心 paged.js 对 content(text) 的处理它在标准规格里是 string() 的取值支持有限。实战中如果取不到值可以改成先给 h1 加一个 data 属性或用 ::before 拼文本。这个功能很适合做提案文档的大型章节拆分每个章节一个标题页眉自动跟随省去为每个章节写死页眉的重复劳动。第一次用 paged.js 做深度定制时我总想着一行脚本就让所有排版问题消失后来才发现它只是把分页能力给到浏览器字体、图片、DOM 事件还是得按打印场景逐个过。从那以后我每次做导出 PDF 的功能都会先在本地把整份文档导出一次检查目录页码、空白页、表格截断三件事再进自动化流程。希望这一套经验能帮你少走几趟弯路做 PDF 导出时心里有底。本文还有配套的精品资源点击获取