专题归档 · 2025 年 12 月(编辑分组,非实际写作日期)
从 html2canvas 到 Paged.js:一次复杂报表打印重构
从位图打印到分页 DOM:vue-print-next、Paged.js、签批区域与长表跨页布局。
在实习时,我接触到一个需要打印长篇业务报表的页面。它不仅有正文和动态表格,底部还有签批区域。屏幕上看起来正常的页面,到了 A4 打印场景,就开始出现文字发虚、长表截断、签批区跨页等问题。
这次任务的目标不是重做整套业务,而是让打印区域成为一个可以控制分页的文档。旧方案依赖 html2canvas,而最终采用的是 vue-print-next + Paged.js。中途也走过不依赖 Paged.js 的尝试;我把这些阶段分开记录,避免把某个过渡版本当成最终方案。
旧流程:业务 DOM 被绘成一张图
按当时保存的实现记录,点击打印后,会带参数跳转到打印详情页面,获取数据并组合正文、动态表格和签批区域,再调用 html2CanvasPrint 将整块 DOM 绘制为 Canvas,交由浏览器打印。
打印按钮
→ printDetail 页面加载业务数据
→ 组合正文、数据表格、签批区域
→ html2canvas 截图 / 位图化
→ 浏览器打印或保存 PDF
这里的问题不是 html2canvas 不能截图,而是位图无法继续按表格行、页眉或签批区域的语义进行分页。图片能被切成多个页面,但切割线可能穿过一行数据;放大后的文字和细表格线也不如保留原生 DOM 的输出清楚。
业务上还有一个比较特别的要求:签批区不应该被一刀切成两半,理想情况下要在每页底部完整呈现。单纯把签批表放到正文末尾,无法保证这些条件。
先研究什么,而不只是换一个插件
当时领导给了 vue-print-next 和 Paged.js 两个技术方向,由我去研究、集成和调整旧打印模块。我的第一步是把原来耦在一起的流程拆开考虑:
- 数据层:取得报表所需的数据与可变长度的表格行。
- 内容层:让 Vue 生成结构合理的正文、表格和签批区域 DOM。
- 分页层:确定纸张尺寸、页边距、表格断点、签批区占用空间。
- 输出层:指定哪块 DOM 参与预览与打印。
这样拆开后,单次打印不再意味着整个页面必须先变成一张图片。
当时考虑过三种实现方向:直接打印 DOM 并依赖原生 CSS 分页;手动把数据按行数拆成逐页容器;或者继续使用 Canvas 再按纸张高度切片。手动分页的边界清晰,却很难用固定的“每页 N 行”应对真实的内容换行和可变行高;画布切片则把分页语义降成了像素计算。
中间尝试:不用 Paged.js 的纯 CSS 打印
历史文件里保留着 paged.ts、printCss、@page、@media print、position: fixed 等配置尝试。那个阶段曾研究在打印媒体中固定签批区、让浏览器自动切分表格,也探索过通过 vue-print-next 的 extraHead 注入脚本。
其中一个当时确实记录了解决结果的小问题,是打印界面只出现第一页:全局容器的固定高度或 overflow 会影响分页内容的显示。旧笔记记录了一个调整方向:
@media print {
html, body, #app {
height: auto !important;
overflow: visible !important;
display: block !important;
}
}
这段 CSS 调整解决了当时“只显示第一页”的一个表现,但不能在所有项目中无条件覆盖 #app。特别是签批区即使使用 position: fixed; bottom: 0,也必须为正文预留空间,否则可能遮挡最后几行。
原来的 paged.ts 草稿还保存了尝试引入 Paged.js Polyfill 的配置片段,形式大致是把脚本放进 vue-print-next 的 extraHead:
// 历史尝试:不是已验证的最终集成方式
const pagedPolyfillUrl = new URL(
'../../../node_modules/pagedjs/dist/paged.polyfill.js',
import.meta.url,
).href
const getPagedExtraHead = (additionalHead = '') =>
`<script src="${pagedPolyfillUrl}"></script>${additionalHead}`
这段记录有助于解释为什么当时在研究打印上下文的JS 注入与执行时序。但 extraHead 里包含 script 并不保证在预览 iframe 内按设想加载,更不能保证脚本只执行一次。它是失败/过渡路径的证据,不应原样作为生产建议。
更重要的是,这段“不使用 Paged.js”的实现只是中间版本。我在 2026 年 10 月回看和更正旧笔记时,明确确认最终交付仍使用了 vue-print-next + Paged.js,不应沿用之前文章里“最终删除 Paged.js”的错误结论。
最终的职责划分:内容、分页预览、打印入口
我现在对两者的理解是:
- Paged.js 读取待排版的 HTML/DOM 与分页样式,计算溢出、创建逐页容器,输出具有
.pagedjs_pages/.pagedjs_page结构的 DOM。这是可以预览的分页 HTML,并非直接生成 PDF 文件。 - vue-print-next 以配置中的
el指定待打印节点,通过preview、extraCss等参数组织打印区域和预览入口,最终仍调用浏览器打印能力。
业务数据
↓
Vue 生成 #report-source 中的结构化 DOM
↓
Paged.js: 内容 + 分页 CSS → #report-pages 中的分页 DOM 树
↓
vue-print-next: el 指向 #report-pages,打开预览/打印
↓
浏览器打印 / 保存为 PDF
这是根据我的方案回忆和两个库的官方 API 重建出的职责流程图,不是对最后一次交付源码逐行恢复。原始笔记没有保留可核验的最终调用时序,不能确认是先调用独立 Previewer.preview() 再交给 vue-print-next,还是在独立打印文档中通过 polyfill 触发分页。两种接法的时序、样式加载和重复分页风险都需要按实际代码检查。
下面是一段演示两个 API 如何配合的示意代码,并非原项目源码:
import { Previewer } from 'pagedjs'
import { VuePrintNext } from 'vue-print-next'
async function previewReport() {
// Vue 数据和组件应先完成渲染,字体、图片也应加载就绪
const source = document.querySelector('#report-source')
const pages = document.querySelector('#report-pages')
if (!source || !pages) throw new Error('缺少打印区域')
pages.replaceChildren()
await new Previewer().preview(source.innerHTML, ['/report-print.css'], pages)
new VuePrintNext({
el: '#report-pages',
preview: true,
extraCss: '/report-print.css',
})
}
如果选择此种模式,要避免源 DOM 和分页结果一起被打印,也不能在已经分页的树上反复运行 Paged.js。不同 CSS 的作用域、iframe/打印窗口内样式,以及异步数据和资源加载,都会影响预览与实际输出的一致性。
表格列宽:JS 可生成规则,但不能替代引擎校验
另一条历史线索是复杂表格的列宽和第二页错位。当时确实尝试过 colgroup、单元格 nth-child、空单元格占位,以及直接为列设置宽度。业务表格本身可以在渲染前按配置生成列定义:
// 示意:用业务层列定义生成 <colgroup> / 表格结构
const columns = [
{ key: 'ref', width: '18%' },
{ key: 'summary', width: '52%' },
{ key: 'state', width: '30%' },
]
// 按 columns 生成完整表格,再让 Paged.js 分页
如果当时要把“调整列表宽度”收敛为一个可测试的 JS 配置接口,我现在会考虑让列定义先作用于原始表格,再交给分页引擎。例如下面这段演示代码:
type PrintColumn = { key: string; width: string }
function applyColumnWidths(
table: HTMLTableElement,
columns: PrintColumn[],
) {
const group = document.createElement('colgroup')
for (const column of columns) {
const col = document.createElement('col')
col.dataset.key = column.key
col.style.width = column.width
group.append(col)
}
table.querySelector(':scope > colgroup')?.remove()
table.prepend(group)
table.style.tableLayout = 'fixed'
table.style.width = '100%'
}
// 先根据配置构造表格,再执行 Previewer.preview(...)
applyColumnWidths(reportTable, columns)
上例是一个供今后重做该方案时参考的 JS 接口设计,不来自 DDCC 最终代码。它需要业务列定义与真实的 td 顺序保持一致;分页前设置 colgroup 也不保证所有跨页列宽问题自动解决。对于跨页分裂后的 DOM,仍然要比较每页的列定义、实际单元格结构和计算后的宽度。
如果分页后仍有列宽错位,还可以通过 Paged.js 的 afterPageLayout Hook 检查单页 DOM 与计算宽度。我把它作为进一步的排查手段,而不是必需的集成步骤。
真正遇到的症状是:当表格第一列为空时,第二页以及之后的行会出现列错位,直接改原始 CSS 宽度未必生效。造成这种现象的候选因素包括分页后的 DOM 结构、colspan/rowspan、colgroup 继承和表格布局规则。当时还需要补充分页前后的最小复现,才能确定是不是空单元格处理或某段列宽逻辑造成的问题。
这类问题最有效的定位方法是同一份数据,对比分页前与分页后的 DOM、单元格数量和 computed width,再将每项 CSS 调整独立试验,而非不断叠加 nth-child、伪元素与占位字符。
相关排障已单独记录:Paged.js 第二页表格列宽错位。
三种“每页重复”不是一回事
复盘时我还发现,开发者谈“重复页眉”时常把三个对象混在一起:
- 报表的文档标题 / LOGO:类似印刷页眉,可评估
@pagemargin boxes 和运行标题。 - 数据表的
thead:属于表格结构,是否续页重复取决于表格/分页布局和浏览器支持。 - 底部签批区:是需要预留实际高度的独立业务块,并不能因为固定定位就保证不遮挡正文。
break-inside: avoid 可以表达不希望拆分某块内容,却不能让高于纸张可打印区域的块凭空塞进一页。最终必须看实际输出 PDF,特别是两页边界、最后一页、空单元格以及签批区域。
最后得到的认识
这次重构把截图式打印改成了基于 DOM 和分页样式的打印方案,最终使用 vue-print-next + Paged.js。它让我能够分别调整内容结构、分页规则和预览入口;复杂表格跨页时的列宽问题则需要继续对比 DOM 和 CSS 的行为。
我现在更重视的工程分界是:内容结构属于业务组件,分页布局属于排版引擎,预览/打印属于输出入口。把它们拆开,后续调试才有机会知道问题究竟是数据、DOM、CSS,还是浏览器打印。
延伸知识:Paged.js 原理、Vue Print Next 配置、CSS 打印样式。