使用 html2canvas + jspdf 将 html 页面转成 pdf 保存,支持分页与页边距

3,212 阅读5分钟

前言

在前端开发中遇到这样一个需求:页面上有一个 download 按钮,点击 download 按钮的时候,需要将当前页面的一部分下载到 pdf 保存。

以下将用到 html2canvasjspdf 这两个库来实现将 HTML 页面转换为 pdf 的功能。

html2canvas

html2canvas 一个开源的 JavaScript 库,可以将 HTML 元素渲染成 canvas,并生成图片。原理是通过遍历页面 DOM 结构,收集所有元素信息及相应样式,渲染出 canvas image。

使用

// 安装
npm install html2canvas

html2canvas 的 API 很简单,以下是一个简单的使用:

import html2canvas from 'html2canvas';

html2canvas(element, {
    // options
}).then(canvas => {
    // canvas is the final rendered <canvas> element
})

jspdf

jspdf 一个用于在客户端生成 pdf 文件的JavaScript 库。

使用

// 安装
npm install jspdf

jspdf 使用 new jsPDF() 来创建 pdf,以下是一个简单的示例:

import { jsPDF } from "jspdf"; 

// Default export is a4 paper, portrait, using millimeters for units 
const pdf = new jsPDF({
    // options
});

pdf.text("Hello world!", 10, 10);
pdf.save("a4.pdf");

html2canvas + jspdf

使用 html2canvas 将 HTML 元素渲染成 canvas,使用 toDataURL 方法将 canvas 生成图片,再将图片按需求添加到 jspdf 创建的 pdf 中,最后保存。

下面是一个简单的 demo :

js 代码部分:

import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';

function downloadHandler(elementId) {
      // elementId 为需要下载的元素的 id
      const element = document.getElementById(elementId);
      if (element) {
        html2canvas(element, {
          useCORS: true, // 允许加载跨域图片
          onclone: (clone) => {
            // clone 为克隆出来的整个页面的 DOM 节点
            if (elementId) {
              const cloneDOM = clone.getElementById(elementId);
              // 将需要下载的元素的宽度调整为 A4 纸的宽度
              // 注意:这里的操作不会修改原 DOM,因此会和页面渲染的不一致,如果需要保持和页面一致则不需要此操作
              cloneDOM.style.width = `592.28px`;
            }
          },
        }).then((canvas) => {
          // canvas 渲染的是需要下载的元素
          const pdf = new jspdf.jsPDF("p", "pt", [592.28, 841.89]); // 创建一个 pdf
          const ctx = canvas.getContext("2d");
          const margin = 30; // pdf 每页的边距
          const a4w = 592.28 - 2 * margin,
            a4h = 841.89 - 2 * margin; // pdf 内容的宽高
          const imgHeight = Math.floor((a4h * canvas.width) / a4w); // 将 canvas 按 pdf 内容的高度分页,imgHeight 为分页的高度
          let renderedHeight = 0; // 已经添加到 pdf 的高度

          while (renderedHeight < canvas.height) {
            // canvas 没有全部添加到 pdf
            const page = document.createElement("canvas"); // 创建一个新的画布
            // 将 canvas 切割后渲染到新的画布上
            page.width = canvas.width;
            page.height = Math.min(imgHeight, canvas.height - renderedHeight);
            page
              .getContext("2d")
              .putImageData(
                ctx.getImageData(
                  0,
                  renderedHeight,
                  canvas.width,
                  Math.min(imgHeight, canvas.height - renderedHeight)
                ),
                0,
                0
              );
            // 将新画布生成图片并添加到 pdf 中
            pdf.addImage(
              page.toDataURL("image/jpeg", 1.0),
              "JPEG",
              margin,
              margin,
              a4w,
              Math.min(a4h, (a4w * page.height) / page.width)
            );
            renderedHeight += imgHeight;
            if (renderedHeight < canvas.height)
              // 如果 canvas 没有被全部添加到 pdf,则新增一页空白 pdf
              pdf.addPage();
            page.remove(); // 移除新的画布
          }
          // 保存 pdf
          pdf.save("demo.pdf");
        });
      }
    }

踩坑记录

1. 图片跨域

在实际开发过程中可能会出现这样一个问题:客户端页面上的 https 图片能正常渲染,而 download 下来的 pdf 加载图片却跨域了。

排查问题发现,页面上的图片 url 都携带一个一次性的签名参数,客户端通过签名参数正常访问图片后,html2canvas 再去请求图片时签名参数失效了,所以跨域无法渲染图片。

解决办法:

第一步:设置储存图片的服务器允许跨域访问(* 为允许所有来源访问,也可以设置为指定的来源),Access-Control-Allow-Origin: *

image.png

第二步:设置 img 标签允许跨域渲染图片,crossOrigin="anonymous"

<img
  src={img.accessUrl}
  alt=""
  width="100%"
  crossOrigin="anonymous"
/>

第三步:设置 html2canvas 允许访问跨域图片,useCORS: true

html2canvas(element, { useCORS: true }).then(canvas => {})

完成以上三步,生成的 pdf 图片显示正常了。

2. 如何分页

一个简单的实现分页的思路:累计长图添加到 pdf 的高度,如果累计高度 < 长图高度,则继续将长图添加到新一页 pdf,否则已经添加完成。

以下是代码实现:

// 计算 canvas 等比变化后的宽度高度(宽度 = pdf 宽 - 2 * margin)
const imageSize = ImageSize.calculate(canvas.width, canvas.height);

let renderedHeight = 0; // 累计添加到 pdf 的高度
let position = 10; // y 坐标,针对页面上边缘

const pageData = canvas.toDataURL('image/jpeg', 1.0); // 长图
const pdf = new jsPDF('p', 'pt', A4Size.SIZE);

while (renderedHeight < imageSize.height) {
  pdf.addImage(pageData, 'jpeg', ImageSize.MARGIN, position, imageSize.width, imageSize.height);
  renderedHeight += A4Size.height;
  position -= A4Size.height;

  if (renderedHeight < imageSize.height) {
    pdf.addPage();
  }
}

由于 canvas 转成图片后是长图,imageSize.height > 每页 pdf 的高度,所以 position 为负数,为新的一页 pdf 开始的高度位置。

3. 如何设置每页的页边距

jsPDF addImage 方法可以通过 x、y 坐标来设置页面的左、上边距,右、下边距需要通过控制图片的宽高来实现。那么对于上面第二个问题的长图来说,从第二页开始就无法设置上下边距了。

这是因为添加到 pdf 每页的都是完整的长图,如果 position > 0 那么每页 pdf 看到的图片都是长图高度为 0 的位置开始。

因此,如果需要设置上下边距,只能将长图截断成适合每页 pdf 的高度,每次添加到 pdf 的都是截断出来的新的一张图片,如此可以实现页面上下边距:

const pdf = new jspdf.jsPDF('p', 'pt', [592.28, 841.89]); // 创建一个 pdf
const ctx = canvas.getContext('2d');
const margin = 30;  // pdf 每页的边距
const a4w = 592.28 - 2 * margin, a4h = 841.89 - 2 * margin;  // pdf 内容的宽高
const imgHeight = Math.floor(a4h * canvas.width / a4w); // 将 canvas 按 pdf 内容的高度切割,imgHeight 为切割的高度
let renderedHeight = 0; // 已经添加到 pdf 的高度

while (renderedHeight < canvas.height) {  // canvas 没有全部添加到 pdf
  const page = document.createElement("canvas");  // 创建一个新的画布
  // 将 canvas 切割后渲染到新的画布上
  page.width = canvas.width;
  page.height = Math.min(imgHeight, canvas.height - renderedHeight);
  page.getContext('2d').putImageData(ctx.getImageData(0, renderedHeight, canvas.width, Math.min(imgHeight, canvas.height - renderedHeight)), 0, 0);
  // 将新画布生成图片并添加到 pdf 中
  pdf.addImage(page.toDataURL('image/jpeg', 1.0), 'JPEG', margin, margin, a4w, Math.min(a4h, a4w * page.height / page.width));
  renderedHeight += imgHeight;
  if (renderedHeight < canvas.height) // 如果 canvas 没有被全部添加到 pdf,则新增一页空白 pdf
    pdf.addPage();
  page.remove();  // 移除新的画布
}