在前端的日常业务中,经常会有需要提供下载 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 文档,可以使用 addFileToVFS 和 addFont 方法直接添加 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');
缺陷
- 样式兼容问题
经过简单测试, html2canvas 对于表格的 border-collapse: collapse;
属性兼容不好。当同时设置border-collapse: collapse;
和 1px 宽度的像素时,生成的图片会至少有 2px 宽度,这需要额外注意。playground 。
- 对 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);
}
}
- 表格分页
由于我们的方案中使用的分页是根据 dom 的高度和页面高度来判断的,如果某个table高度大于当前页高度的话,会导致排版错乱,可能需要进行额外的处理。
总结
本文简单介绍了使用 html2canvas 和 jspdf 进行前端打印的挑战、方案、实现和缺陷。由于 pdf 打印需求的多样性,本文所介绍的内容难免会有没覆盖到的场景,欢迎大家积极交流。