项目地址
最近重新改造了三年前写的一个 PDF 打印工具 Web Printer,现在更新到了 3.0。
这个项目最早用于财务系统的报表打印,主要解决表格跨页截断的问题。后来发现,真正困难的不是将 HTML 转成 PDF,而是如何在保留网页原有布局的前提下,找到正确的分页位置,并稳定导出长文档。
目前最常见的浏览器端方案之一是 html2pdf.js。它基于 html2canvas 和 jsPDF,接入简单,但在复杂长文档中存在一些已知限制。
GitHub Issues 里也能找到对应的真实反馈:
- #692:长文本在分页位置被截断。
- #393:表格分页出现逐页累积的位置偏差。
- #19:长文档超过 Canvas 尺寸限制,导出空白。
- #512:使用
avoid-all后,分页产生不合理的空白区域。
针对这些问题,我重新设计了分页和渲染流程。
一、内容感知分页:不再直接按像素切割
html2pdf.js 的基本流程是:
HTML → DOM 克隆 → html2canvas 整页截图 → 按高度切割 → jsPDF
它也支持 CSS 分页和 avoid 规则,通过在元素前后插入占位节点调整分页位置。
但最终仍然是在 Canvas 上按照固定像素高度切图。当分页边界落在文字行中间时,就可能把一行文字切成两半。
Web Printer 采用另一种思路:先理解内容边界,再决定在哪里分页。
1. 元素分类
对 DOM 进行遍历时,将需要处理的内容分为三类:
| 分类 | 典型内容 | 分页规则 |
|---|---|---|
| Atomic | 图片、SVG、代码块、显式保护区域 | 整体保留,不跨页 |
| Splittable | 段落容器、表格、一般布局容器 | 递归分析子节点,寻找安全断点 |
| Text | 文字节点 | 按实际渲染的文字行测量 |
其中,表格行采用“整块优先”的策略:一行放得下就整体保留,超过单页高度则继续分析内部内容。严格 Atomic 元素如果超过单页容量,则明确报错。
分类规则独立维护于 print-rules.ts。
2. 文字行测量
这是分页中比较关键的一步。
DOM 元素的高度容易获取,但一个段落可能包含很多行文字。如果只知道整个段落的边界,就无法实现按行分页。
Web Printer 使用浏览器原生 Range.getClientRects() 获取文本在实际布局中的几何位置:
1 | const range = document.createRange(); |
随后结合 line-height 和可见裁剪区域,将这些矩形转换成受保护的纵向区间:
1 | type Interval = { |
这些区间表示:分页线不应该穿过这段可见内容。
实际实现见 text-range.ts。这里的行高补偿是基于 CSS 行高的近似计算,并不是完整重建浏览器的 line box。
3. 安全断点计算
完成测量后,分页算法不再依赖 DOM。
输入只有三个核心参数:
- 文档总高度
height - 单页可用高度
capacity - 需要保护的区间
protectedRanges
采用贪心分页策略,先尝试当前页面能够容纳的最大高度。如果断点落入受保护区间,就向前回退到该区间起点。
核心逻辑可以简化为:
1 | let end = Math.min(start + capacity, height); |
实际实现会先排序、合并重叠区间,再通过游标逐页推进,避免每一页重复扫描全部区间。
完整算法见 paginator.ts。
这套设计的关键是:分页计算只产生坐标,不通过插入空白节点来改变原有 DOM 的布局。
二、视觉保真:分页不能反过来改变页面
解决了截断问题,接下来又遇到另一个问题:分页正确,不代表导出后的页面看起来正确。
真实业务页面可能有 Flex、Grid、自动宽度、嵌套容器、滚动区域,以及 fixed、sticky 等定位方式。
如果直接把 DOM 移动到新的打印容器,很容易触发重新布局(Reflow),造成元素宽度、文字换行和位置变化。
Web Printer 3.0 的解决方案是建立一个离屏打印快照(Print Snapshot)。
主要处理包括:
- 等待字体、图片等资源就绪,避免在临时布局状态下测量。
- 克隆需要打印的 DOM,复制计算后的 CSS 样式。
- 锁定打印根元素宽度,尽可能保留原有换行与布局。
- 复制表单当前值、Canvas 内容和内部滚动状态。
- 将 fixed、sticky 等定位状态冻结为快照中的当前几何位置。
之后,测量、分页和截图都基于同一份快照进行。
这样可以避免分页计算和实际渲染使用不同布局,也不会直接修改业务页面。
相关代码位于 snapshot.ts。
3.0 还移除了旧版分页规则中的 ignore 分类,避免分页引擎默认删除某些元素而改变布局。需要打印什么内容,优先由调用方选择打印根节点来确定。
这里的“所见即所得”是一个尽量保持打印视图几何与样式的设计目标,而不是保证能够还原任意网站的所有 CSS 特性。
三、长文档:从一次性超长截图改为逐页渲染
传统整页截图方案还有一个限制:Canvas 的尺寸并不是无限的。
当 HTML 文档很长时,一次性生成超大 Canvas,可能触及浏览器的最大尺寸或内存限制,最终导致空白 PDF。
Web Printer 把测量分页与 Canvas 渲染分离。
整个流程变成:
DOM 快照 → 布局测量 → 分页计划 → 逐页 Canvas → PDF / PNG
先生成所有分页区间,再逐页执行截图:
1 | for (const page of pages) { |
这里不是为每页重新排列一份 DOM,而是从同一份冻结快照中,按照计算好的纵向区间截图。
每张截图都有独立的像素预算:最多 800 万像素、单边最大 8192 像素,必要时降低缩放比例。截图写入 PDF 后立即释放当前 Canvas。
相关实现:
这种方案降低了单张超大 Canvas 导致导出失败的风险,同时可以复用分页结果输出 PDF 或逐页 PNG。
但它并不意味着内存占用固定,也不意味着长文档导出一定更快:当前每页仍需调用 html2canvas,整体耗时会随页数增长。
四、Benchmark:与 html2pdf.js 实测对比
为了验证这些改造,我使用 Playwright 搭建了自动化 Benchmark,对三个版本进行比较:
- html2pdf.js 0.14.0
- Web Printer 2.0.0
- Web Printer 3.0 开发版本
测试环境为 macOS、Chromium 141、A4 纵向、DPR 1,每个场景运行 3 次,耗时取中位数。
1. 导出性能
| 场景 | html2pdf.js | Web Printer 3.0 | 结果 |
|---|---|---|---|
| 长文本(2 页) | 1,761 ms | 759 ms | 快约 2.3 倍 |
| 复杂表格(3 页) | 3,117 ms | 1,237 ms | 快约 2.5 倍 |
| 长文档(5 页) | 4,382 ms | 2,167 ms | 快约 2.0 倍 |
| Flex / Grid 布局 | 校验失败 | 630 ms | 通过校验 |
| 长文档(20 页) | 校验失败 | 9.96 s | 通过校验 |
| 长文档(50 页) | 校验失败 | 34.98 s | 通过校验 |
在本机这批测试样本中,5 页以内的成功导出场景,Web Printer 的耗时约为 html2pdf.js 的 40%–50%。
2. 长文档正确性
Benchmark 不只检查导出是否成功,还使用 PDF.js 重新读取实际生成的 PDF,逐页验证内容。
20 页和 50 页样本包含预设的顺序颜色标记,用于判断内容是否丢失、重复或生成空白页。
| 测试 | html2pdf.js | Web Printer 3.0 |
|---|---|---|
| 20 页 | 内容标记重复,校验失败 | 3/3 通过 |
| 50 页 | 50 页全部空白 | 3/3 通过 |
50 页样本中,html2pdf.js 的输出为全白 PDF,未检出预期的 500 个标记;Web Printer 则通过全部内容校验。
需要说明的是,这不意味着 html2pdf.js 在所有 20 页或 50 页文档上都会失败,结果与文档实际像素尺寸和浏览器环境有关。
3. 视觉还原
另外,对原页面与离屏快照进行了几何和像素对比。
在固定样本中,Web Printer 3.0 的文本、表格、Flex/Grid 布局,测量坐标最大差值均为 0,文字行数量没有变化。
滚动容器样本中,2.0 版本曾出现最大 160px 的坐标偏差,3.0 修复后降为 0。
因此,3.0 相比 2.0 的主要改进是布局正确性和快照一致性,而不是进一步提高导出速度。
完整测试参数、8 组结果以及复现方法都在仓库中:
五、使用 Web Printer
安装:
1 | npm install @darialan/web-printer |
导出已经渲染好的 DOM:
1 | import { outputDocument } from '@darialan/web-printer'; |
对于不希望跨页拆开的签名区、图片组合等内容,可以显式标记:
1 | <div data-print-atomic="true"> |
目前 Web Printer 的目标不是兼容任意交互式网页,而是为报表、单据、合同和长文章等已经排版好的打印视图,提供更可靠的分页导出。
输出的 PDF 仍然是位图页面,不支持直接选择和搜索文本;部分 CSS 特性及跨域资源也会受到 html2canvas 的限制。
项目已经开源,欢迎体验和反馈实际业务场景。