likes
comments
collection
share

html2canvas和jspdf导出pdf,及截断问题处理

作者站长头像
站长
· 阅读数 4

一.前言

业务上经常会遇到一些导出pdf文件的需求,有些时候整个pdf文件导出的功能直接放在了后端去完成,前端只需要根据接口去下载就行,特别是一些动态写入或者隐私签章之类的,作为一个前端,当然很满意后端的辛苦付出啦!

但是显然并不是所有的后端都这么友好,甚至有一些复杂的pdf内容格式放在后端做也比较费劲,那么就可能需要前端去单独完成这个文件写入导出pdf的功能了。目前针对pdf文件导出,前端用的比较多的有这两款插件:html2canvasjspdf

原理:

  • 前端根据需求绘制dom,渲染需要展示的pdf文件的格式内容;
  • 使用html2canvas插件把指定的dom元素转成canvas画布,并得到对应图片的base64数据源;
  • 使用jspdf插件把图片base64数据源写入到pdf上,并导出,得到对应的pdf文件;

上面我们简单介绍了关于使用这两款插件导出pdf的原理,方便大家更直接的理解和使用。

下面我们将根据不同的业务场景去合理利用这两个插件导出我们需要的pdf文件,尤其是涉及如何根据实际情况选择不同的方式避免或者解决内容截断这个问题。

二.实现

下面会先列出一种目前比较标准的导出pdf的过程,当然这个标准方法有一个致命的问题:当图片写入到pdf上并,自动换行时,就会出现内容截断的问题

我们先说明下为什么会出现内容截断的问题:当我们使用html2canvas把dom元素转成canvas对应的base64图片数据源时,我们紧接着是把图片渲染到pdf上。当这个图片内容足够长时,pdf一页的长度不够插入,这个时候就需要分页(这里其实也算不上自动分页,是我们调用pdf的api手动分页的),但是由于图片是连续的,它在一页的pdf上是从上到下渲染的,渲染到本页最下面时,就可能会出现一行文字只渲染到上半截部分,该页就写不了,剩下一半截文字只能渲染到下一页,这就是内容截断。

上面我们描述了下为什么会出现截断的情况,大家有个心理预期,后面我们会讲解如果去避免这个问题。

1.标准模式

针对上面的描述,其实我们心理应该对导出pdf的过程有了一个清晰的认知,无非就是怎么调用这两个插件的api去完成我们的功能。

下面会列出一个目前比较标准的常规写法(以下是基于Vue的一些伪代码)。

// 根据dom获取图片数据:注意纵向时A4尺寸是592.28*841.89,故横向时就是841.89*592.28
getCanvasToImage(dom, xGap = 80) {
	if (typeof dom === 'string') dom = document.querySelector(dom);
	return new Promise((resolve, reject) => {
		html2canvas(dom, {
			allowTaint: false,
			tainTest: false,
			useCORS: true,
			dpi: window.devicePixelRatio * 2,
			scale: 2
		}).then(canvas => {
			// 这是实际dom转canvas的画布尺寸
			let contentWidth = canvas.width;
			let contentHeight = canvas.height;
			// 实际渲染到pdf上图片的尺寸(把canvas按比例缩放成A4的尺寸,考虑到我们需要保留左右边距)。以下纵向为例
			let imgWidth = 592.28 - xGap; // 保留了总的横向xGap的边距,下面让图片在x轴上的xGap/2位置渲染就可保证在x轴居中
			let imgHeight = imgWidth / contentWidth * contentHeight;
			let imageData = canvas.toDataURL('image/jpg');
			resolve({ imageData, imgWidth, imgHeight });
		})
	})
}


// 导出pdf标准模式(无顶部边距):注意我们的案例保留了左右边距
handleExportPDF() {
	const dom = document.querySelector('.content-list'); // 获取实际需要渲染的dom
	const xGap = 80; // 设置总的横向边距(包含左右)
	this.getCanvasToImage(dom, xGap).then(({ imageData, imgWidth, imgHeight }) => {
		let tempTotalHeight = imgHeight; // 临时变量 记录原imgHeight高度,实际还剩多少没绘制
		let position = 0; // 临时变量 记录y轴位置
		const PDF = new jsPDF('', 'pt', 'a4'); // 注意第一个参数为空是纵向,为l则横向
		while (tempTotalHeight > 0) {
			// 注意第三第四参数图片在PDF中x轴和y轴的位置,轴方向参考canvas,y轴下为正,上为负,轴心在当前页的左上角
			// xGap/2保证x轴居中;
			PDF.addImage(imageData, 'JPEG', xGap / 2, position, imgWidth, imgHeight);
			position -= 841.89; // 每绘制完一页,图片在pdf轴中上移一页的y轴距离,好让需要绘制的下一页内容展示出来
			// 计算剩余未绘制的图片高度:其实每页绘制的高度就是a4纸高度841.89
			tempTotalHeight = tempTotalHeight - 841.89 ;
			if (tempTotalHeight > 0) {
				PDF.addPage(); // 当还有剩余图片未绘制完则分页,在下一页中绘制
			}
		}
		PDF.save('测试.pdf');
	});
}

// 只需要调用handleExportPDF即可
this.handleExportPDF();

上面是一个标准的导出pdf模式,为了pdf文件内容的优雅,我们特意设置了左右边距。这个标准导出过程,相信大家还是比较容易理解的。当然这种模式前面我们也说了,如果图片足够长,会出现内容截断问题,这一点需要明白。

2.带顶部边距模式

上面的标准模式我们并没有设置顶部边距,而只是设置了左右边距。

下面我们就设置一下顶部边距,但是在此之前有一点需要说明,当图片内容比较长需要pdf做分页时,第二及后面的页我们无法设置顶部的距离,也就是说由于图片的绘制是连续的,我们只能保证第一页有顶部边距。

其实大家可以仔细想想就知道,我们在绘制的过程中,PDF.addImage第三第四的参数是图片在pdf坐标系的x和y位置,我们自始至终是上下左右移动图片,保证需要绘制的内容在当然pdf页的主区域来绘制。

之所以把设置第一页顶部边距的模式单独拎出来,主要是因为避免读者不好理解这个计算的过程。

// 根据dom获取图片数据:注意纵向时A4尺寸是592.28*841.89,故横向时就是841.89*592.28
getCanvasToImage(dom, xGap = 80) {
	if (typeof dom === 'string') dom = document.querySelector(dom);
	return new Promise((resolve, reject) => {
		html2canvas(dom, {
			allowTaint: false,
			tainTest: false,
			useCORS: true,
			dpi: window.devicePixelRatio * 2,
			scale: 2
		}).then(canvas => {
			// 这是实际dom转canvas的画布尺寸
			let contentWidth = canvas.width;
			let contentHeight = canvas.height;
			// 实际渲染到pdf上图片的尺寸(把canvas按比例缩放成A4的尺寸,考虑到我们需要保留左右边距)。以下纵向为例
			let imgWidth = 592.28 - xGap; // 保留了总的横向xGap的边距,下面让图片则x轴上的xGap/2位置渲染就可保证在x轴居中
			let imgHeight = imgWidth / contentWidth * contentHeight;
			let imageData = canvas.toDataURL('image/jpg');
			resolve({ imageData, imgWidth, imgHeight });
		})
	})
},

// 导出pdf(有顶部边距):注意我们的案例保留了左右边距和顶部边距
handleExportPDF() {
	const dom = document.querySelector('.content-list');
	const xGap = 80; // 设置总的横向边距(包含左右)
	const topGap = 40; // 顶部边距(实际上当图片一次性绘制有分页时,该插件只能设置第一页的上边距,无法设置连续分页后其他页面顶部边距)
	this.getCanvasToImage(dom, xGap).then(({ imageData, imgWidth, imgHeight }) => {
		let tempTotalHeight = imgHeight; // 临时变量 记录原imgHeight高度,后面计算实际还剩多少没绘制
		let position = 0; // 临时变量 记录y轴位置
		let flag = false; // 临时变量 标识自动分页了
		const PDF = new jsPDF('', 'pt', 'a4'); // 注意第一个参数为空是纵向,为l则横向
		while (tempTotalHeight > 0) {
			// 注意第三第四参数图片在PDF中x轴和y轴的位置,轴方向参考canvas,y轴下为正,上为负
			// xGap/2保证x轴居中;
			// topGap+position保证第一页有上边距topGap,该插件绘制pdf时都是一直绘制到当前页底部,自动分页绘制后面页时也都是从顶部绘制
			// 这里有个点要清楚,每加一页position减841.89即上移一页的高度,因为当前页的841.89高度绘制完了,继续绘制剩余的部分
			PDF.addImage(imageData, 'JPEG', xGap / 2, topGap + position, imgWidth, imgHeight);
			position -= 841.89; // 每绘制完一页,图片在pdf轴中上移一页的y轴距离,好让需要绘制的下一页内容展示出来
			// 计算剩余未绘制的图片高度:在有顶部边距的情况,如果是连续自动分页,则只有第一页绘制的高度是841.89-topGap,其余都是841.89高度,因为第一页顶部边距不算实际绘制的图片
			tempTotalHeight = tempTotalHeight - 841.89 + (flag ? topGap : 0);
			if (tempTotalHeight > 0) {
				PDF.addPage();
				flag = true;
			}
		}
		PDF.save('测试.pdf');
	});
},

// 只需要调用handleExportPDF即可
this.handleExportPDF();

在标准模式的基础上,我们又增加了首页顶部边距,同标准模式一样,如果图片高度很长需要分页,同样有截断问题的出现。当然,如果我们需要渲染的图片内容都不够绘制满一页,那么我们压根不需要考虑截断问题,因为不涉及分页,就不会出现截断情况,那么上面两种模式读者自行选择使用即可。

3.指定每页绘制的dom模式

前面两种模式,当图片内容很高需要换行时,就会出现截断问题,那么我们应该如何避免内容被截断呢?

我们仔细想一想,jspdf在绘制图片的时候是连续的,如果图片足够长就一定需要分页,一分页就不可避免要面对截断问题,那么我们是不是可以想办法让jspdf每次绘制的图片都不超过一页A4(这里我们以A4举例)的高度?

没错,要解决这个问题,就需要我们限制html2canvas获取的文本图片高度,我们可以把一个长dom拆分成多个dom去绘制多个canvas,并获取多个图片数据,再把多个图片分别绘制到一页pdf上,这要就避免了截断问题。

// 根据dom获取图片数据:注意纵向时A4尺寸是592.28*841.89,故横向时就是841.89*592.28
getCanvasToImage(dom, xGap = 80) {
	if (typeof dom === 'string') dom = document.querySelector(dom);
	return new Promise((resolve, reject) => {
		html2canvas(dom, {
			allowTaint: false,
			tainTest: false,
			useCORS: true,
			dpi: window.devicePixelRatio * 2,
			scale: 2
		}).then(canvas => {
			// 这是实际dom转canvas的画布尺寸
			let contentWidth = canvas.width;
			let contentHeight = canvas.height;
			// 实际渲染到pdf上图片的尺寸(把canvas按比例缩放成A4的尺寸,考虑到我们需要保留左右边距)。以下纵向为例
			let imgWidth = 592.28 - xGap; // 保留了总的横向xGap的边距,下面让图片则x轴上的xGap/2位置渲染就可保证在x轴居中
			let imgHeight = imgWidth / contentWidth * contentHeight;
			let imageData = canvas.toDataURL('image/jpg');
			resolve({ imageData, imgWidth, imgHeight });
		})
	})
},

// 导出pdf(指定不同dom):注意我们的案例保留了左右边距和每页的顶部边距
handleExportPDF() {
	const doms = document.querySelectorAll('.content-list'); // 获取实际需要渲染的dom集合
	const xGap = 80; // 设置总的横向边距(包含左右)
	const topGap = 40; // 顶部边距(每一页的顶部边距)
	Promise.all(Array.prototype.map(doms, dom => this.getCanvasToImage(dom, xGap))).then((imageArr, index) => {
		imageArr.map(({ imageData, imgWidth, imgHeight }) => {
			PDF.addImage(imageData, 'JPEG', xGap / 2, topGap, imgWidth, imgHeight);
			// 不是最后一页都换页
			if (imageArr.length - 1 !== index) {
				PDF.addPage();
			}
		})
		PDF.save('测试.pdf');
	})
},

// 只需要调用handleExportPDF即可
this.handleExportPDF();

我们通过把最初的绘制一个dom的图片数据换成了绘制多个dom的图片数据,来规避因为图片过长导致分页截断的问题,现在我们保证了每一个dom的图片数据内容都只是在单页的pdf里面。

案例中我们用的是document.querySelectorAll('.content-list')去获取集合,实际情况需要读者根据业务情况去处理,这里我就以我以前做过的一个案例去描述,当然下面只是描述,代码编写需要读者自行去操作,想必这并不能难倒大家。

案例:导出一个pdf,上面是一些汇总的文本和图片logo之类的,下面是一个数据量不确定的动态的table表格,采用的是element-ui的el-table渲染的,这里的表格每行的高度是相对固定的,只是行数不确定而已。

实现思路:

  • 处理后台请求的数据,我会把table的数据处理,提取30条数据(读者可根据实际情况选择);
  • 这30条数据单独用el-table渲染,和上面的logo及汇总的文本组成一个dom;
  • 剩下的table数据,我进行了分组,每50条数据为一组,单独遍历用el-table渲染,这就动态的获取了dom集合,当然我们需要css控制非第一个el-table隐藏header;
  • 然后用上面的模式把dom集合转成图片base64数据源并绘制到pdf上;

上面我简单介绍了一个特殊的情况,旨在于告知读者需要针对实际情况去灵活解决问题。

4.自适应绘制dom模式(第3种的延伸)

其实上面三种模式,已经可以解决绝大多数的用户导出pdf的场景了,那么为什么还有这里的第4种情况呢? 我们可以回头去看看第3种模式的分析过程,我们第3种模式的前提是dom集合中每个dom的高度是相对固定的,而且这个dom本身是不能太长的,如果太长的话以至于放不到一个pdf页面里面,那第3种模式依旧会出现截断问题。

举个例子,如果有一个dom元素,它是表格,这个表格是不标准的,有各种行合并和列合并,这个表格的高度是不确定的,甚至行合并的行高度都是不确定的,那么用第3种模式根本解决不了截断问题,因为你压根没办法指定某一个dom为第一页某一个为第二页,这种情况导出必然会导致表格的边框甚至内容被截断。

那么,我们又如何解决这种截断问题呢?

在此之前我想完善话我上面提到的例子,因为这就是我目前开发的一个业务场景。

例子:这个一个非常复杂的表格,这个表格支持编辑,有输入框、复选框、单选框,有很多行合并和列合并,行合并中,有按钮可以增加行和删除行,因此合并行中的行高不确定,有很多这种大模块,需要根据上面的单选确定渲染几个模块,根据单选和复选结果确定是否渲染新的输入框......,这是一个非常复杂的非常多字段的调查需求。当然为了方便导出,我是让非编辑状态下才支持导出,非编辑状态输入框字段我都渲染成了span,因为发现el-input在导出时会出现文字不对齐的情况,当然这是额外的说明,考虑到业务的复杂程度,笔者用的是原生的table去渲染的。

大家可以思考下这个例子,看看有什么比较好的实现导出pdf的思路,下面我就针对这个案例找出的第4种模式,其实也是第3种模式的延伸。

实现思路:

  • 和第3种一样,避免截断的唯一方式就是让每一页pdf绘制的图片数据高度是不能超过一页pdf的容器高度的,从而保证不能因为图片过长而被动换行触发截断;
  • 但是我们又没办法控制动态dom的高度,因此,我们可以手动指定一个变量控制我们一页pdf绘制的最大高度(不能超过pdf单页高度),这是我们能在一页pdf中所能绘制的最大实际图片渲染高度;
  • 我们把整个大dom拆分成小非常多的dom;
  • 把每一个dom单独转换成canvas的图片源数据,获取到图片数据源的集合(注意必须先转成图片源,因为实际绘制到pdf上的是图片源,我们要用实际图片源高度进行比较);
  • 遍历这个集合,按顺序累加每一个图片的实际高度,和上面指定的最大高度比较,把累积高度后刚好不大于这个临界最大值的所有图片源分到一个组,从而得到n组图片数据源的集合,即一个二维数组,显然这个二维数组中的每一个元素,即图片源集合刚好绘制到一页pdf上,最终得到动态的pdf分页结果;

上面就整个实现过程进行了较为详细的阐述,相信大家应该都能理解,下面列出代码: 本案例中我是把tr作为单一dom,但是考虑到合并行有多个tr,此时如果也以tr为基准,会导致合并行tr中的文字(多行居中显示)会被不同的tr覆盖,以及合并行跨页边框被截断情况。因此合并行的多个tr外层有tbody包裹,让tbody作为单一dom,非合并行依旧以tr为单一dom,故我们只需要获取到table元素的children就可以得到所有的单一dom了。

// 根据dom获取图片数据:注意纵向时A4尺寸是592.28*841.89,故横向时就是841.89*592.28
getCanvasToImage(dom, xGap = 80) {
	if (typeof dom === 'string') dom = document.querySelector(dom);
	return new Promise((resolve, reject) => {
		html2canvas(dom, {
			allowTaint: false,
			tainTest: false,
			useCORS: true,
			dpi: window.devicePixelRatio * 2,
			scale: 2
		}).then(canvas => {
			// 这是实际dom转canvas的画布尺寸
			let contentWidth = canvas.width;
			let contentHeight = canvas.height;
			// 实际渲染到pdf上图片的尺寸(把canvas按比例缩放成A4的尺寸,考虑到我们需要保留左右边距)。以下纵向为例
			let imgWidth = 592.28 - xGap; // 保留了总的横向xGap的边距,下面让图片则x轴上的xGap/2位置渲染就可保证在x轴居中
			let imgHeight = imgWidth / contentWidth * contentHeight;
			let imageData = canvas.toDataURL('image/jpg');
			resolve({ imageData, imgWidth, imgHeight });
		})
	})
},

			// 计算剩余未绘制的图片高度:在有顶部边距的情况,如果是自动分页,则只有第一页是绘制的高度是841.89-topGap,其余都是841.89高度
// 导出pdf(获取小dom集合,分组绘制到pdf上):注意我们的案例保留了左右边距和顶部边距
handleExportPDF() {
	const domWrap = document.querySelectorAll('.detail-table'); // 获取最外层dom
	const doms = domWrap.children;
	const xGap = 80; // 设置总的横向边距(包含左右)
	const topGap = 40; // 顶部边距(每一页的顶部边距)
	let totalHeight = 0; // 累积的总高度
	const tempMaxHeight = 750; // 最大高度,可根据需求自定义 
	let arrIndex = 0; // 二维数组元素的索引
	const dyadicArrImages = []; //二维数组,里面每一个元素就是每一页pdf的图片源集合
	// 获取每一个dom对应的图片源数据对象
	Promise.all(Array.prototype.map(doms, dom => this.getCanvasToImage(dom, xGap))).map(temp => {
		const { imgHeight } = temp;
		totalHeight += imgHeight;
		// 累积的总高度和最大绘制总高度比较
		if (totalHeight > tempMaxHeight) {
			arrIndex++;
		}
		// 追加二维数据元素
		if (!dyadicArrImages[arrIndex]) dyadicArrImages[arrIndex] = [];
		dyadicArrImages[arrIndex].push(temp);
	})
	// 遍历每一组
	dyadicArrImages.map((arrImages, index) => {
		// 绘制每一页
		let position = topGap;
		arrImages.map(({ imageData, imgWidth, imgHeight }) => {
			// 注意这里是上移高度position,因为该层的遍历是在绘制同一页,绘制的一个图片源就需要移动图片源在pdf坐标轴的位置
			PDF.addImage(imageData, 'JPEG', xGap / 2, position, imgWidth, imgHeight);
			position += imgHeight; // 保证下一个图片源绘制在上一个图片源的下面
		})
		if (arrImages.length - 1 !== index) {
			position = topGap; // 重置位置:新的一页从顶部开始
			PDF.addPage(); //切下一页
		}
	})
	PDF.save('测试.pdf');
},

// 只需要调用handleExportPDF即可
this.handleExportPDF();

上面详细的描述了关于设置了pdf最大实际绘制高度时的自动绘制,并分页,避免截断的模式。 当然上面的案例是基于我的实际业务场景设计的获取元素的方式,统一在table的children中获取,读者可针对各自的实际业务场景调整适合自己的获取dom方式。

比如当我们在这个业务基础上,前面再加一个logo和汇总文本呢?那无非就是先获取这部分的图片源高度,再获取table子dom集合时,第一组需要先加上这部分的高度罢了,读者需要灵活运用。

三.总结

针对html2canvas和jspdf导出pdf,并处理截断问题,上面阐述了4种方式,当然其实第一种和第二种是一样的,无非就是多了顶部边距罢了。总之,读者需要根据自身的业务情况去合理选择并调整。

转载自:https://juejin.cn/post/7359833391191425065
评论
请登录