项目地址

最近重新改造了三年前写的一个 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
2
3
4
5
6
7
const range = document.createRange();

range.selectNodeContents(textNode);

const rects = Array.from(
range.getClientRects()
);

随后结合 line-height 和可见裁剪区域,将这些矩形转换成受保护的纵向区间:

1
2
3
4
type Interval = {
top: number;
bottom: number;
};

这些区间表示:分页线不应该穿过这段可见内容。

实际实现见 text-range.ts。这里的行高补偿是基于 CSS 行高的近似计算,并不是完整重建浏览器的 line box。

3. 安全断点计算

完成测量后,分页算法不再依赖 DOM。

输入只有三个核心参数:

  • 文档总高度 height
  • 单页可用高度 capacity
  • 需要保护的区间 protectedRanges

采用贪心分页策略,先尝试当前页面能够容纳的最大高度。如果断点落入受保护区间,就向前回退到该区间起点。

核心逻辑可以简化为:

1
2
3
4
5
6
let end = Math.min(start + capacity, height);

// 如果分页线穿过受保护区域
if (range.top < end && range.bottom > end) {
end = range.top;
}

实际实现会先排序、合并重叠区间,再通过游标逐页推进,避免每一页重复扫描全部区间。

完整算法见 paginator.ts。

这套设计的关键是:分页计算只产生坐标,不通过插入空白节点来改变原有 DOM 的布局。

二、视觉保真:分页不能反过来改变页面

解决了截断问题,接下来又遇到另一个问题:分页正确,不代表导出后的页面看起来正确。

真实业务页面可能有 Flex、Grid、自动宽度、嵌套容器、滚动区域,以及 fixed、sticky 等定位方式。

如果直接把 DOM 移动到新的打印容器,很容易触发重新布局(Reflow),造成元素宽度、文字换行和位置变化。

Web Printer 3.0 的解决方案是建立一个离屏打印快照(Print Snapshot)。

主要处理包括:

  1. 等待字体、图片等资源就绪,避免在临时布局状态下测量。
  2. 克隆需要打印的 DOM,复制计算后的 CSS 样式。
  3. 锁定打印根元素宽度,尽可能保留原有换行与布局。
  4. 复制表单当前值、Canvas 内容和内部滚动状态。
  5. 将 fixed、sticky 等定位状态冻结为快照中的当前几何位置。

之后,测量、分页和截图都基于同一份快照进行。

这样可以避免分页计算和实际渲染使用不同布局,也不会直接修改业务页面。

相关代码位于 snapshot.ts。

3.0 还移除了旧版分页规则中的 ignore 分类,避免分页引擎默认删除某些元素而改变布局。需要打印什么内容,优先由调用方选择打印根节点来确定。

这里的“所见即所得”是一个尽量保持打印视图几何与样式的设计目标,而不是保证能够还原任意网站的所有 CSS 特性。

三、长文档:从一次性超长截图改为逐页渲染

传统整页截图方案还有一个限制:Canvas 的尺寸并不是无限的。

当 HTML 文档很长时,一次性生成超大 Canvas,可能触及浏览器的最大尺寸或内存限制,最终导致空白 PDF。

Web Printer 把测量分页与 Canvas 渲染分离。

整个流程变成:

DOM 快照 → 布局测量 → 分页计划 → 逐页 Canvas → PDF / PNG

先生成所有分页区间,再逐页执行截图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for (const page of pages) {
const canvas = await capture(
root,
page.start,
page.end - page.start
);

try {
await writer.addPageSlice(
index,
canvas,
pageHeight
);
} finally {
release(canvas);
}
}

这里不是为每页重新排列一份 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 Benchmark

五、使用 Web Printer

安装:

1
npm install @darialan/web-printer

导出已经渲染好的 DOM:

1
2
3
4
5
6
7
import { outputDocument } from '@darialan/web-printer';

await outputDocument({
element: document.getElementById('report')!,
filename: 'report',
format: 'pdf',
});

对于不希望跨页拆开的签名区、图片组合等内容,可以显式标记:

1
2
3
<div data-print-atomic="true">
<!-- 需要整体保留的内容 -->
</div>

目前 Web Printer 的目标不是兼容任意交互式网页,而是为报表、单据、合同和长文章等已经排版好的打印视图,提供更可靠的分页导出。

输出的 PDF 仍然是位图页面,不支持直接选择和搜索文本;部分 CSS 特性及跨域资源也会受到 html2canvas 的限制。

项目已经开源,欢迎体验和反馈实际业务场景。