基于 html2canvas 和 jspdf 的前端 pdf 打印下载方案

在前端的日常业务中,经常会有需要提供下载 pdf 的功能,可能是下载数据报表、发票、合同凭证等等。一般来说,pdf 文件是由后端服务进行生成的。后端服务能够获取足够的信息用于生成 pdf 文件,后端能够存储 pdf 文件缓存已供多次下载,后端生成 pdf 可以以异步的方式发送至多端比如浏览器直接下载、邮件、短信等。

不过后端生成 pdf 也有一定的局限性,后端生成 pdf 一般是先设置文件的模板,然后根据用户的请求填充对应的数据,然后提供给用户。在这种场景下,文件模板一般由产品经理和业务方确认后提供给后端进行生成,模板的样式一般较为简陋,比较适用于简单文档、数据表格等内容。但是偶尔也会有需要由前端开发进行生成 pdf 文件的场景,比如说文档样式比较复杂、或者样式允许用户自定义的情况。

html2canvas 和 jspdf

html2canvas 是一个用于将 dom 转换为图片的工具,它的工作原理是实现了一套 dom 的渲染引擎,通过获取传入的 dom 的信息,在 canvas 中重绘 dom。

jspdf 是基于 html2canvas 的 pdf 生成工具。它能够向传统的 pdf 生成工具一样向文档添加特定的文本、图像等元素内容,也支持传入 dom 生成特定结构的内容。

如果需要前端生成 pdf 文件,一般采用以下两个方案。1. 使用 html2canvas 将dom 转为图片然后插入到 jspdf 中;2. 直接将 dom 传入 jsPDF.html() 方法中。

方案一 方案二
生成所需时间 x0.5 x1
生成文件大小 x0.1 x1
对比 1. 速度快 2. 生成文件小 3. 纯图片,文字部分无法选中 1.速度慢 2. 生成文件非常大 3. 允许选中文字部分,体验更好

挑战和方案

中文字体支持

jspdf 默认是不支持中文字体的。采用方案一的话是不需要考虑这个问题,毕竟文字都被处理到图片中了。如果采用方案二,需要额外设置中文字体,并且嵌入到生成的 pdf 中。

分页

分页是一个非常重要的部分,也是前端生成 pdf 的难点,网络上也有很多方案,多是使用 html2canvas 生成 image 之后,将图片裁剪然后放入不同的页面中。裁剪的方式有多种,如果图片像素较少,可以直接遍历像素,找到行内容最少的部分进行裁切;或者在转为图片前遍历 dom 并计算元素是否会跨页,然后转为图片放入 pdf 之后将被分页的元素遮白处理等。

水印

对于需要特定权限访问的内容,一般都会添加水印用于跟踪用户。使用 jspdf 时需要水印需要自行添加水印内容,然后调整透明度即可。

实现

添加中文字体

根据最新的 jspdf 文档,可以使用 addFileToVFSaddFont 方法直接添加 ttf 格式的字体文件。

ini 复制代码
const doc = new jsPDF();

const fontPath1 = 'static/PingFang-SC-Regular.ttf';
doc.addFileToVFS('PingFang-SC-Regular.ttf', doc.loadFile(fontPath1));
doc.addFont('PingFang-SC-Regular.ttf', 'PingFang SC', 'normal');

const fontPath2 = 'static/PingFang-SC-Bold.ttf';
doc.addFileToVFS('PingFang-SC-Bold.ttf', doc.loadFile(fontPath2));
doc.addFont('PingFang-SC-Bold.ttf', 'PingFang SC', 'bold');

水印

在项目中一般会使用第三方库来生成覆盖网页的水印比如 vue-watermark 或者 vue-watermark-directive 。这些水印库的原理一般是根据给定的配置,使用 canvas 绘制水印内容,然后在页面添加一个覆盖全屏的 dom 并添加生成的水印文件为 background-image。在这里以后者举例,大家在使用 v-watermask 生成为页面添加水印之后,会在页面中留有一个水印 dom,我们只需要取到这个 dom 中的背景图片 base64 即可。

ini 复制代码
/** 获取水印的尺寸 */
function getImageDimensions(dataUrl: string) {
  return new Promise((resolve, reject) => {
    let img = new Image();
    img.onload = function() {
      resolve({ width: this.width, height: this.height });
    }
    img.onerror = function() {
      reject(new Error('Could not load image from data URL'));
    }
    img.src = dataUrl;
  });
}

let watchmaskDataUrl = ''

// https://github.com/ZhuHuaijun/vue-watermark-directive/blob/master/src/watermark.js#L37
const watermaskDiv = /** @type {HTMLElement | undefined} */(document.querySelector('#vue-watermark-directive'));
if(watermaskDiv) {
  // https://github.com/ZhuHuaijun/vue-watermark-directive/blob/master/src/watermark.js#L51
  watchmaskDataUrl = watermaskDiv.style.backgroundImage.replace('url("', '').replace('")', '');  
}

const doc = new jsPDF();

if(watchmaskDataUrl) {
  const { width, height } = await getImageDimensions(watchmaskDataUrl)
  for(let i= 1; i < doc.getNumberOfPages() + 1; i++) {
    doc.setPage(i);
    doc.saveGraphicsState();
    doc.setGState(doc.GState({opacity: 0.1, 'stroke-opacity': 0.1})); // 设置透明度

    for(let j = 0; j < Math.round(297/20); j++) {
      doc.addImage(watchmaskDataUrl, 'PNG', 0, j * 20, width, height);
      doc.addImage(watchmaskDataUrl, 'PNG', width, j * height, width, height);
    }
    doc.restoreGraphicsState(); // 重置
  }
}

分页处理

我设计的方案是,首先计算分页的高度,然后将会被裁切的元素直接移动到下一页中。

ini 复制代码
/** 按比例计算的每页的px高度 */
const a4Height = source.offsetWidth / (210-20) * (297-20); 

/** 当前是第几页了 */
let pageIdx = 1;

/** 增加的高度 */
let addedHeight = 0;


/**
 * @param {HTMLElement} _element 
 */
function dfs(_element) {
  // 下一页
  if(_element.offsetTop >= a4Height * pageIdx) {
    pageIdx++;
  }

  // 元素高度大于1页
  if(_element.offsetHeight > a4Height) {
    const children = /** @type {HTMLElement[]} */(Array.from(_element.children));
    for (let i = 0; i < children.length; i++) {
      const child = children[i];
      bfs(child);
    }
  }
  
  if (
    // 元素高度小于1页
    _element.offsetHeight < a4Height 
    // 元素的顶部在当前页
    && _element.offsetTop < a4Height * pageIdx 
    // 元素的底部在下一页
    && _element.offsetTop + _element.offsetHeight > a4Height * pageIdx
  ) {
    const marginTop = a4Height * pageIdx - _element.offsetTop;
    // ! 在这里为元素增加 margin-top,使元素移动到下一页中
    _element.style.marginTop = `${marginTop}px`;
    addedHeight += marginTop;
    return;
  }

  const children = /** @type {HTMLElement[]} */(Array.from(_element.children));
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    dfs(child);
  }
}

接下来我们以两种方案分别说明一下如何处理分页问题。

方案一:使用 html2canvas 将 dom 转为图片后插入 pdf 中

在这个方案中,我们需要计算每页 pdf 所需要显示的图片内容高度然后将图片切割之后插入到文档。需要注意的是,由于我们需要对dom进行调整,所以在以上的处理之前,需要先复制一份 dom ,然后在复制的 dom 上进行分页调整。接着使用 html2canvas 将我们处理后的 dom 转换为图片。

ini 复制代码
/** @type {HTMLElement} */
// @ts-ignore
const element = source.cloneNode(true);

/**
 * @param {number} ms
 */
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

const warpper = document.createElement('div');

warpper.style.position = 'fixed';
warpper.style.overflow = 'hidden';
warpper.style.zIndex = '1000';
warpper.style.left = '-100000px';
warpper.style.right = '0';
warpper.style.bottom = '0';
warpper.style.top = '0';
warpper.style.width = source.offsetWidth + 'px';
warpper.style.height = source.offsetHeight + 'px';
warpper.appendChild(element);
document.body.appendChild(warpper);

// 等待 5ms ,避免获取不到 dom 。
await sleep(5);

/** @type {HTMLElement[]} */(Array.from(element.children)).forEach(e => {
  dfs(e);
});

// 调整高度,否则canvas绘制不完整
warpper.style.height = source.offsetHeight + addedHeight + 'px';

const canvas = await html2canvas(element, {
  allowTaint: true,
  useCORS: true,
});
const ctx = canvas.getContext('2d');

// 缩放比例
const scale = canvas.width / source.offsetWidth;

接下来我们需要根据 a4 纸的高度和原始 dom 的尺寸进行计算,然后将图片放入 pdf 中。这里需要注意的一点是,我们不希望我们的内容是直接贴边处理的,一方面贴边处理很不美观,另一方面贴边的 pdf 打印时会因为打印机出血而导致内容被裁剪。所以我们在接下来的代码中会为每页 pdf 设置一个 10mm 的 padding。

ini 复制代码
/**
 * @param {ImageData} imageData 
 */
function imageData2DataUrl(imageData) {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  if(!ctx) {
    return '';
  }
  canvas.width = imageData.width;
  canvas.height = imageData.height;
  ctx.putImageData(imageData, 0, 0);
  return canvas.toDataURL('image/jpeg'); // 使用 jpeg 减小生成体积
}

const doc = new jsPDF();

for(let i = 1; i <= pageIdx; i++) {
  doc.setPage(i);
  
  let imageHeight = a4Height;
  if(imageHeight * i > element.offsetHeight) {
    imageHeight = element.offsetHeight - imageHeight * (i - 1);
  }

  // 截图图片
  const imageData = ctx?.getImageData(0, (i - 1) * a4Height * scale, canvas.width, imageHeight * scale);

  doc.addImage(
    imageData2DataUrl(imageData), 
    'JPEG',
    10, // x
    10, // y
    210-20, // w
    imageHeight / source.offsetWidth * (210-20), // h
  );

  if(i !== pageIdx) {
    doc.addPage();
  }
}

// 下载
doc.save(title + '.pdf');

方案二:使用 jspdf.html() 直接将 dom 转换为 pdf

和方案一不同的是,使用 jspdf 内置了过载 dom 的过程,我们只需要调用相关的接口即可。

php 复制代码
/** @type {HTMLElement} */
// @ts-ignore
const element = source.cloneNode(true);

const doc = new jsPDF();

// 在这里可以加载字体
// ...

const worker = doc.html(element, {
  // https://github.com/parallax/jsPDF/blob/master/src/modules/html.js#L1085
  // 这里我们使用 worker 模式来手动处理打印过程
  worker: true,
  html2canvas: {
    allowTaint: true,
    useCORS: true,
  },
  margin: [10, 10, 10, 10], // 10mm 的 padding
  autoPaging: 'slice',
  x: 0,
  y: 0,
  /** 190 = 210(a4纸宽) - 10 * 2(padding) */
  width: 190,
  windowWidth: source.offsetWidth || 980, //window width in CSS pixels
})

await worker.from(element);
await worker.toContainer();

const html = await worker.get('container');

/** @type {HTMLElement[]} */(Array.from(html.children)).forEach(e => {
  dfs(e);
});

await worker.toPdf();

await worker.save(title + '.pdf');

缺陷

  1. 样式兼容问题

经过简单测试, html2canvas 对于表格的 border-collapse: collapse; 属性兼容不好。当同时设置border-collapse: collapse; 和 1px 宽度的像素时,生成的图片会至少有 2px 宽度,这需要额外注意。playground

  1. 对 canvas、 iframe 兼容性较差

由于我们需要使用 source.cloneNode(true) 来复制 dom,所以部分元素会在复制之后无法正常显示,这里记录下关于 iframe 和 canvas 的处理过程。

ini 复制代码
// iframe
const _iframes = source.querySelectorAll('iframe');
const iframes = element.querySelectorAll('iframe');
for (let i = 0; i < iframes.length; i++) {
  const iframe = iframes[i];
  // 创建一个新的div元素
  const div = document.createElement('div');
  // 将iframe的内容添加到div中
  // 并模拟 body 撑满外部元素的行为
  div.innerHTML = _iframes[i].contentDocument?.body.innerHTML 
    ? `<div style="height: 100%">${_iframes[i].contentDocument?.body.innerHTML}</div>` 
    : '';
  // 拷贝 class 和 style
  div.className = iframe.className;
  div.style.cssText = iframe.style.cssText;
  // 将iframe替换为div
  iframe.parentNode?.replaceChild(div, iframe);
}

// 处理canvas元素
const _canvases = source.querySelectorAll('canvas');
const canvases = element.querySelectorAll('canvas');
for (let i = 0; i < canvases.length; i++) {
  const canvas = canvases[i];
  const img = document.createElement('img');
  img.crossOrigin = 'Anonymous';
  img.src = _canvases[i].toDataURL('image/jpeg');
  canvas.parentNode?.replaceChild(img, canvas);
}

// 把图片都转为dataurl;
const imgs = element.querySelectorAll('img');
for (let i = 0; i < imgs.length; i++) {
  const img = imgs[i];
  img.style.width = '100%';
  const src = img.getAttribute('src');
  if (src && src.startsWith('http')) {
    const dataUrl = await convertToDataURL(src);
    img.setAttribute('src', dataUrl);
  }
}
  1. 表格分页

由于我们的方案中使用的分页是根据 dom 的高度和页面高度来判断的,如果某个table高度大于当前页高度的话,会导致排版错乱,可能需要进行额外的处理。

总结

本文简单介绍了使用 html2canvas 和 jspdf 进行前端打印的挑战、方案、实现和缺陷。由于 pdf 打印需求的多样性,本文所介绍的内容难免会有没覆盖到的场景,欢迎大家积极交流。

相关推荐
1024小神6 分钟前
在GitHub action中使用添加项目中配置文件的值为环境变量
前端·javascript
齐尹秦14 分钟前
CSS 列表样式学习笔记
前端
Mnxj18 分钟前
渐变边框设计
前端
用户76787977373221 分钟前
由Umi升级到Next方案
前端·next.js
快乐的小前端22 分钟前
TypeScript基础一
前端
北凉温华23 分钟前
UniApp项目中的多服务环境配置与跨域代理实现
前端
源柒24 分钟前
Vue3与Vite构建高性能记账应用 - LedgerX架构解析
前端
Danny_FD25 分钟前
常用 Git 命令详解
前端·github
stanny25 分钟前
MCP(上)——function call 是什么
前端·mcp
1024小神31 分钟前
GitHub action中的 jq 是什么? 常用方法有哪些
前端·javascript