4

使用jsPDF导出PDF文件实践分享

 1 year ago
source link: https://www.zhangxinxu.com/wordpress/2023/06/js-canvas-jspdf-export-pdf/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

by zhangxinxu from https://www.zhangxinxu.com/wordpress/?p=10854 鑫空间-鑫生活
本文欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可以联系授权。

PDF封面图

一、jsPDF项目简介

最近遇到个需要将网页中特定内容转为PDF的需求,所以有机会试用了下jsPDF,也遇到了一些问题,这里分享给大家了。

首先,项目地址:https://github.com/parallax/jsPDF

目前2.6万的star数,可以说是Github上Top级别的项目了,也是Web导PDF的首选解决方案。

Star数目示意

官方的使用示意也很简单,构造,内容和保持。

import { jsPDF } from "jspdf";

// 默认是 a4 纸张尺寸,纵向,单位是mm
const doc = new jsPDF();

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

好,接下来,就是把页面中的DOM内容作为PDF内容就可以了,理论上如此,但实际使用却令人深思。

二、内置html()方法惊为天人

jsPDF内置一个名为html()的方法,可以直接让HTML元素作为PDF的内容。

var doc = new jsPDF();
// element就是需要转变为pdf的DOM元素
doc.html(element, {
   callback: function (doc) {
     doc.save();
   },
   x: 10,
   y: 10
});

结果我试了下,效果惊为天人。

内置html方法的惊人效果

研究一番,发现是要导入完整的中文字体,而一个中文字体,少说5-6M,在Web这种场景下就不太合适。

于是改变策略,决定先用html2canvas将内容转成图片,再以图片的方式,一页一页地插入到PDF中,实现成本会低很多,不足就是文字无法选择。

三、借助html2canvas

html2canvas这个项目之前也有提过,接近3万 Star,也是Github上的顶级开源项目。

项目地址:https://github.com/niklasvh/html2canvas

极简使用示意

这里快速演示下如何使用html2canvas和jsPDF生成PDF文件。

假设页面上有个布局元素,id属性值是element,效果如下图所示:

示意布局图

则下面的这段代码就可以让这段布局内容变成PDF文件下载下来。

<script src="./html2canvas.min.js"></script>
<script src="./jspdf.umd.min.js"></script>
<script>
var pdf = new jspdf.jsPDF();
html2canvas(target).then(function(canvas) {
    pdf.addImage(canvas.toDataURL('image/jpeg'), 10, 10);
    pdf.save('mybook.pdf');
});
</script>

此时的PDF打开效果就是这样的:

PDF效果示意

眼见为实,您可以狠狠地点击这里:html2canvs与jspdf生成PDF简易demo

然而,真实的项目开发要比demo页面麻烦的多。

几乎大多数人一定会遇到的问题,那就是如果html2canvas生成的图片过长,该如何在PDF中分页。

以及,图片跨域了,又当如何处理?

四、图片跨域和分页的问题

canvas与图片跨域的问题,我之前专门撰文讲解过,传送门地址:“解决canvas图片getImageData,toDataURL跨域问题

里面的方法虽多,根据我多年的实践,还是服务器设置Access-Control-Allow-Origin运行访问,前端fetch或XMLHttpRequest获取图片数据的策略最好用。

以fetch举例,想要获得一个图片地址是 imgUrl 的图像数据,可以这么处理:

fetch(imgUrl).then(res => res.blob()).then(blob => {
  var reader = new FileReader() ;
  reader.onload = function () {
    // this.result 就是图片的base64地址
  };
  reader.readAsDataURL(blob) ;
})

为何需要转成base64呢?因为根据我的实践,html2canvas内容中的图片地址,需要转换成base64地址,才能真正可用。

分页的问题

分页的问题可以使用代码搞定,设置好每一页PDF的高度,然后canvas的高度一页一页剪掉再分别添加即可。

这里就有一些需要提前知道的,关于尺寸的知识。

当我们构造一个 pdf 实例的时候,如果不设置任何参数,则其尺寸是 A4 纸的尺寸,210毫米×297毫米。

根据我的实践,这个尺寸有些小了,生成的PDF内容模糊,阅读体验极为不佳。

所以,有必要对PDF尺寸进行自定义,也就是设置得大一些,虽然大尺寸让PDF文件占据空间也大了,但现在是大屏高清时代,流量不值钱,此不足不值一提。

具体而言,我是这么处理的。

首先确定好页面中容器元素的宽度,假设是700px,则PDF的尺寸可以设置为2倍,也就是1400px,而竖版PDF的高宽比是根号二,也就是1.414,所以PDF的高度就是1400*1.414=1979.6像素。

此时,再配合简单的数学计算,我们就可以将canvas图像分隔成一页一页的,分别塞在PDF中。

具体代码参见下一节封装的代码示意。

五、封装后的的方法

为了方便遇到类似需求的同学可以快速完成对应的开发。

我将上面的图像处理和分页封装成了可复用的方法,代码如下所示(支持jspdf和html2canvas src直连和 npm install 安装两种使用形式):

// 导出 pdf 封装方法
// by zhangxinxu(.com)
// 访问 https://www.zhangxinxu.com/wordpress/?p=10854 了解更新信息
export async function exportPdf (element, filename = '未命名', callback = () => {}) {
  if (!element) {
    callback();
    return;
  }

  // 尺寸的确定
  const originWidth = element.offsetWidth || 700;

  // 创建一个容器,用于克隆元素
  const container = document.createElement('div');
  // 16px是为了生成的PDF有安全边距
  container.style.cssText = `position:fixed;left: ${-2 * originWidth}px; top:0;padding:16px;width:${originWidth}px;box-sizing:content-box;`;
  // 插入到body中
  document.body.appendChild(container);
  // 克隆元素
  container.appendChild(element.cloneNode(true));

  // 依赖的库
  var jsPDF;

  if (typeof html2canvas == 'undefined') {
    html2canvas = await import('html2canvas').then(module => module.default);
  }

  if (typeof jspdf == 'undefined') {
    jsPDF = await import('jspdf').then(module => module.jsPDF);
  } else {
    jsPDF = jspdf.jsPDF;
  }

  // 为了保证显示质量,2倍PDF尺寸
  const scale = 2;
  const width = originWidth + 32;

  const PDF_WIDTH = width * scale;
  const PDF_HEIGHT = width * 1.414 * scale;

  // 渲染方法
  const render = function () {
    // 渲染为图片并下载
    html2canvas(container, {
      scale: scale
    }).then(function(canvas) {
      const contentWidth = canvas.width;
      const contentHeight = canvas.height;

      // 一页pdf显示html页面生成的canvas高度
      const pageHeight = contentWidth / PDF_WIDTH * PDF_HEIGHT;

      // canvas图像在画布上的尺寸
      const imgWidth = PDF_WIDTH;
      const imgHeight = PDF_WIDTH / contentWidth * contentHeight;

      let leftHeight = contentHeight;
      let position = 0;

      const doc = new jsPDF('p', 'px', [PDF_WIDTH, PDF_HEIGHT]);

      // 不足一页
      if (leftHeight < pageHeight) {
        doc.addImage(canvas, 'PNG', 0, 0, imgWidth, imgHeight);
      } else {
        // 多页
        while (leftHeight > 0) {
          doc.addImage(canvas, 'PNG', 0, position, imgWidth, imgHeight)
          leftHeight -= pageHeight;
          position -= PDF_HEIGHT;
          //避免添加空白页
          if (leftHeight > 0) {
            doc.addPage();
          }
        }
      }

      doc.save(filename + '.pdf');

      // 移除创建的元素
      container.remove();

      // 隐藏全局loading提示
      callback();
    });
  }

  // 图像地址替换成base64地址
  const eleImgs = container.querySelectorAll('img');
  const length = eleImgs.length;
  let start = 0;
  container.querySelectorAll('img').forEach(ele => {
    let src = ele.src;

    if (!src) {
      return;
    }

    // 事件处理,必须成功或失败
    ele.onload = function () {
      if (!/^http/.test(ele.src)) {
        start++;
        if (start == length) {
          render();
        }
      }
    };

    // 请求图片并转为base64地址
    fetch(src).then(res => res.blob()).then(blob => {
      var reader = new FileReader() ;
      reader.onload = function () {
        ele.src = this.result;
      };
      reader.readAsDataURL(blob) ;
    }).catch(() => {
      // 请求异常处理
      start++;
      if (start == length) {
        render();
      }
    });
  });
}

可以自动将容器元素中的图片Base64,同时内容分布在每一页的PDF上并下载。

实践出真知

为了验证封装的方法的效果,我特意做了个演示页面。

//zxx: 演示页面采用的是直联调用

您可以狠狠地点击这里:exportPdf封装方法与跨域图片PDF导出demo

打击可以点击下图所示的“PDF生成”按钮,菊花转几圈之后,就可以看到PDF下载的提示了(看你浏览器设置,也可能是直接保持到本地)。

导出PDF示意

下图是生成的PDF文件的缩略图,可以看到图片和排版都是完全符合预期的。

导出PDF示意图

调用这块的JS代码参考:

<script src="./html2canvas.min.js"></script>
<script src="./jspdf.umd.min.js"></script>
<script type="module">
import { exportPdf } from './exportPdf.js';
// 点击按钮执行PDF导出
button.addEventListener('click', () => {
    const article = document.querySelector('article');
    // 显示loading
    button.loading = true;
    // 由于导出PDF是异步的,所以需要在导出完成后隐藏loading
    exportPdf(article, '最终章 极北大迷宫', () => {
        button.loading = false;
    });
});
</script>

少了很多处理细节,是不是实现起来简单多了。

六、跨行内联背景色的渲染问题

实际使用中,还遇到了比较棘手的问题。

就是内联元素,如果有背景色,且这个背景色换行了,则生成的PDF的这部分色块会覆盖部分内容,导致异常。

例如页面渲染是这样的:

原始布局效果示意

PDF效果却是这样的:

内联色块bug

我查了下html2canvas的issues,有多个类似反馈,但是都没有进行处理。

按照我对html2canvas底层实现方式的理解,这个问题确实不太好处理。

但是,并不表示没有方法。

可以将整块的内联元素,分隔成一个一个独立的内联元素,这样就可以正常渲染了。

也就是将这个结构:

<span class="bgcolor">CSS新世界</span>

转换成这样子的(实际开发不能换行,这里是为了方便大家阅读刻意处理的):

<span>
    <span class="bgcolor">C</span>
    <span class="bgcolor">S</span>
    <span class="bgcolor">S</span>
    <span class="bgcolor">新</span>
    <span class="bgcolor">世</span>
    <span class="bgcolor">届</span>
</span>

来看下最终的效果。

您可以狠狠地点击这里:解决html2canvas span inline background色块问题demo

点击下面这个按钮,JS会对原来的DOM结构进行处理(实际开发可以克隆该元素再处理,以避免DOM结构变化会带来潜在风险):

修复按钮点击示意

此时,生成的PDF效果就和原始布局样式效果保持一致了:

生成效果保持一致了

七、其他点点点

本文示意页面所使用的JS文件都是script直连。

实际开发,多是走前端框架。

由于这两个JS都是体积比较大的JS,因此,可以使用动态加载的方式来实现。

import('html2canvas').then(module => module.default).then(...)
import('jspdf').then(module => module.jsPDF).then(...)

盼星星盼月亮

《CSS选择器世界 第2版》的签字版也已经可以购买啦,打开手机淘宝,扫下图左下角的码就可以了。

书籍购买码

//zxx: 包邮,另外,我这里还有三张极客时间14天畅学卡,先购买的优先赠送之。

OK,其他就没什么好说的,希望本文的内容可以帮到遇到类似需求的小伙伴。

❤️ 🧡 💛 💚 💙 💜

(本篇完)1f44d.svg 是不是学到了很多?可以分享到微信
1f44a.svg 有话要说?点击这里


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK