← [ BACK_TO_NOTES ]

专题归档 · 2025 年 12 月(编辑分组,非实际写作日期)

从 html2canvas 到 Paged.js:一次复杂报表打印重构

从位图打印到分页 DOM:vue-print-next、Paged.js、签批区域与长表跨页布局。

#blog #frontend #vue

在实习时,我接触到一个需要打印长篇业务报表的页面。它不仅有正文和动态表格,底部还有签批区域。屏幕上看起来正常的页面,到了 A4 打印场景,就开始出现文字发虚、长表截断、签批区跨页等问题。

这次任务的目标不是重做整套业务,而是让打印区域成为一个可以控制分页的文档。旧方案依赖 html2canvas,而最终采用的是 vue-print-next + Paged.js。中途也走过不依赖 Paged.js 的尝试;我把这些阶段分开记录,避免把某个过渡版本当成最终方案。

旧流程:业务 DOM 被绘成一张图

按当时保存的实现记录,点击打印后,会带参数跳转到打印详情页面,获取数据并组合正文、动态表格和签批区域,再调用 html2CanvasPrint 将整块 DOM 绘制为 Canvas,交由浏览器打印。

打印按钮
  → printDetail 页面加载业务数据
  → 组合正文、数据表格、签批区域
  → html2canvas 截图 / 位图化
  → 浏览器打印或保存 PDF

这里的问题不是 html2canvas 不能截图,而是位图无法继续按表格行、页眉或签批区域的语义进行分页。图片能被切成多个页面,但切割线可能穿过一行数据;放大后的文字和细表格线也不如保留原生 DOM 的输出清楚。

业务上还有一个比较特别的要求:签批区不应该被一刀切成两半,理想情况下要在每页底部完整呈现。单纯把签批表放到正文末尾,无法保证这些条件。

先研究什么,而不只是换一个插件

当时领导给了 vue-print-next 和 Paged.js 两个技术方向,由我去研究、集成和调整旧打印模块。我的第一步是把原来耦在一起的流程拆开考虑:

  1. 数据层:取得报表所需的数据与可变长度的表格行。
  2. 内容层:让 Vue 生成结构合理的正文、表格和签批区域 DOM。
  3. 分页层:确定纸张尺寸、页边距、表格断点、签批区占用空间。
  4. 输出层:指定哪块 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:类似印刷页眉,可评估 @page margin boxes 和运行标题。
  • 数据表的 thead:属于表格结构,是否续页重复取决于表格/分页布局和浏览器支持。
  • 底部签批区:是需要预留实际高度的独立业务块,并不能因为固定定位就保证不遮挡正文。

break-inside: avoid 可以表达不希望拆分某块内容,却不能让高于纸张可打印区域的块凭空塞进一页。最终必须看实际输出 PDF,特别是两页边界、最后一页、空单元格以及签批区域。

最后得到的认识

这次重构把截图式打印改成了基于 DOM 和分页样式的打印方案,最终使用 vue-print-next + Paged.js。它让我能够分别调整内容结构、分页规则和预览入口;复杂表格跨页时的列宽问题则需要继续对比 DOM 和 CSS 的行为。

我现在更重视的工程分界是:内容结构属于业务组件,分页布局属于排版引擎,预览/打印属于输出入口。把它们拆开,后续调试才有机会知道问题究竟是数据、DOM、CSS,还是浏览器打印。

延伸知识:Paged.js 原理、Vue Print Next 配置、CSS 打印样式。

API 参考

创建于 2025/12/2 更新于 2026/10/17
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本