基于 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 打印需求的多样性,本文所介绍的内容难免会有没覆盖到的场景,欢迎大家积极交流。

相关推荐
GIS程序媛—椰子1 小时前
【Vue 全家桶】7、Vue UI组件库(更新中)
前端·vue.js
DogEgg_0011 小时前
前端八股文(一)HTML 持续更新中。。。
前端·html
ZL不懂前端1 小时前
Content Security Policy (CSP)
前端·javascript·面试
木舟10091 小时前
ffmpeg重复回听音频流,时长叠加问题
前端
王大锤43911 小时前
golang通用后台管理系统07(后台与若依前端对接)
开发语言·前端·golang
我血条子呢2 小时前
[Vue]防止路由重复跳转
前端·javascript·vue.js
黎金安2 小时前
前端第二次作业
前端·css·css3
啦啦右一2 小时前
前端 | MYTED单篇TED词汇学习功能优化
前端·学习
半开半落2 小时前
nuxt3安装pinia报错500[vite-node] [ERR_LOAD_URL]问题解决
前端·javascript·vue.js·nuxt