产品需求
- 将html中指定内容转换为 pdf,
- pdf 换页时不能从元素中间截断,比如图片、输入框(支持自定义),
- pdf 页面四周要留白,
- 页面底部显示页码。
安装插件
- npm install -S html2canvas jspdf
- html2canvas doc
- jspdf doc
API介绍
// orientation-打印方向,可以是横向或纵向,有 "p"、"l" 两个参数
// format 表示 PDF 的尺寸,默认a4
// unit 表示单位,打印都同意使用 pt
const pdf = new JSPDF({ orientation: 'p', format: 'a4', unit: 'pt' });
// scale 表示缩放,
// useCORS 是否尝试使用CORS从服务器加载图像
// allowTaint 是否允许跨源图像污染画布
const canvas = await html2canvas(element, { scale: 2, useCORS: true, allowTaint: true, });
const imageData = canvas.toDataURL('image/jpeg', 1.0);
// 绘制PDF
pdf.addImage(imageData, 'jpeg', x, y, w, h);
// 在当前PDF页面后面新增一个页面,并将焦点移动到新页面
pdf.addPage();
// 删除指定的PDF页面
pdf.deletePage(count);
// 设置填充的颜色(类似 canvas 的 fillStyle)
pdf.setFillColor('#ffffff');
// 从(0, 0)位置开始绘制一个595.28 * 100 的矩形
pdf.rect(0, 0, 595.28, 100, 'F');
// 设置绘制文字的颜色
pdf.setTextColor('#b3b3b3');
// 设置绘制文字大小
pdf.setFontSize(12);
// 在坐标xy处绘制文字,align - 水平方向对其方式; baseline-垂直方向对其方式
pdf.text(text, x, y, { align: 'center', baseline: 'middle' });
如何避免在分页时将元素截断
首先,要实现这个功能,我们需要设计一个能够遍历所有后代节点的算法,算法中需要计算每个后代元素相对文档的偏移量(getElementOffsetTop方法可以完成计算)。我们还需要一个参考坐标(origin)来计算出每个后代元素相对偏移量(dist),同时,我们还应该有一个 PDF 的内容高度(除去了四周的留白)pdf$safeAreaHeight作为判断跨页的参考。
通过判断 dist + elementOffsetHeight 是否大于 pdf$safeAreaHeight,如果大于说明这个元素跨页了,此时我们需要再定义一个 completeWork() 方法完成最后的计算(得出具体分页的位置),如果小于我们就继续遍历当前节点的下一个兄弟节点(如果 nextElementSibling === null,则回到父节点)。最终遍历完所有的后代节点。
再此过程中,我们还要处理一些细节问题:
- 只对元素节点进行遍历;
- 忽略某些节点,如:
<colgroup>, <td>, <link>, <script>, <svg>; - 对于水平方向布局的内容不对其子节点进行遍历,比如说:一行多列的这种,或者 flex-flow: row 这种布局;
- 节点添加
data-minimum-unit-pdf属性,就不会遍历该节点下的子节点了;
支持自定义
- 支持自定义下载的pdf的文件名
- 可以手动添加可以被忽略的节点名称,匹配的节点以及节点下的所有后代节点都将被忽略
- 可以手动添加可以被忽略的节点,匹配的节点以及节点下的所有后代节点都将被忽略
- 支持手动传入 html2canvas options
- 支持手动传入 pdfOptions, 同时支持定义 safeAreaOffset(留白)
完整代码
import html2canvas from 'html2canvas';
import JSPDF from 'jspdf';
type Html2CanvasOptions = {
scale: number,
useCORS: boolean,
allowTaint: boolean,
x: number,
y: number,
scrollX: number,
scrollY: number,
};
type PdfOptions = {
orientation: 'p' | 'l',
format: 'a4',
safeAreaOffset: [number, number],
showPageNumber: boolean,
};
const defaultHtml2CanvasOptions = {
scale: 2,
useCORS: true,
allowTaint: true,
x: 0,
y: 0,
scrollX: 0,
scrollY: 0,
};
const defaultPdfOptions: Required<PdfOptions> = {
orientation: 'p',
format: 'a4',
safeAreaOffset: [50, 20],
showPageNumber: true,
}
// protrait: Array[0]-宽度,Array[1]-高度。
// landscape: Array[1]-宽度,Array[0]-高度。
const width$height = {
a4: [ 595.28, 841.89 ],
};
type PrintClassProps = {
element: HTMLElement,
html2CanvasOptions?: Partial<Html2CanvasOptions>,
pdfOptions?: Partial<PdfOptions>,
fileName?: string,
}
class PrintPDF {
public h2cOpts: Html2CanvasOptions;
public pdfOpts: PdfOptions;
public element: HTMLElement;
public pdf$safeAreaWidth: number;
public pdf$safeAreaHeight: number;
public pdf$width: number;
public pdf$height: number;
public pdf: any;
public fileName: string;
constructor(props: PrintClassProps) {
const { element, html2CanvasOptions, pdfOptions, fileName = 'default' } = props;
this.element = element;
this.h2cOpts = Object.assign({}, defaultHtml2CanvasOptions, html2CanvasOptions);
this.pdfOpts = Object.assign({}, defaultPdfOptions, pdfOptions);
this.fileName = fileName.endsWith('.pfd') ? fileName : fileName + '.pdf';
// safeAreaOffset 表示 xy 方向的距离安全区域的便宜量。
const { orientation, format, safeAreaOffset } = this.pdfOpts;
const [ pdf$width, pdf$height ] = width$height[format];
/**
* 创建一个 pdf 实例对象
* @params { orientation } 打印的方向(A4 纸横向打印还是纵向)。p => protrait; l => landscape。
* @params { formate } PDF 的格式(尺寸),默认 "a4"。你也可以通过定义尺寸,例如 [ 595.28, 841.89 ]。
* @params { unit } 单位。用于打印时统一使用 "pt" 作为单位。
*/
this.pdf = new JSPDF({ orientation, format, unit: 'pt' });
if (orientation === 'p') {
this.pdf$width = pdf$width;
this.pdf$height = pdf$height;
} else {
this.pdf$width = pdf$height;
this.pdf$height = pdf$width;
}
this.pdf$safeAreaWidth = this.pdf$width - (safeAreaOffset[1] * 2);
this.pdf$safeAreaHeight = this.pdf$height - (safeAreaOffset[0] * 2);
}
ignoreTagNames = new Set<String>(['colgroup', 'td', 'link', 'script', 'svg']);
ignoreNodes = new WeakSet<HTMLElement>();
addIgnoreNodes(node: HTMLElement) {
this.ignoreNodes.add(node);
}
addIgnoreTagNames(tagName: string) {
this.ignoreTagNames.add(tagName);
}
async print() {
let canvas = null;
try {
canvas = await html2canvas(this.element, this.h2cOpts);
} catch (error) {
return Promise.reject(error);
}
const { scale } = this.h2cOpts;
const imgData = canvas.toDataURL('image/jpeg', 1.0);
const { width: canvas$width, height: canvas$height } = canvas;
// 一页 PDF 的高度转换成 HTML 的高度。
const pdfHeight2htmlHeight = canvas$width / this.pdf$safeAreaWidth * this.pdf$safeAreaHeight / scale;
// html 转换成 pdf 的宽度
const pdfContentWidth = this.pdf$safeAreaWidth;
// html 转换成 pdf 的高度(这里是所有要打印内容的高度)
const pdfContentHeight = canvas$height * this.pdf$safeAreaWidth / canvas$width;
// 遍历要打印的 HTML 内容的根节点,计算出每个 PDF 页面内容底部的偏移量(相对 this.element.offsetTop)
const pos = this.traversNode(this.element, pdfHeight2htmlHeight);
// 将计算出的 pos 转换成 pdf 中的位置。
const positions = pos.map(item => -item * scale * pdfContentHeight / canvas$height);
const safeAreaOffsetLeft = this.pdfOpts.safeAreaOffset[1];
const safeAreaOffsetTop = this.pdfOpts.safeAreaOffset[0];
let count = 0;
do {
/**
* 在每个 PDF 页面中中间位置绘制 PDF 内容部分。
* addImage(data, format, x, y, w, h);
* x、y 表示内容相对于左上角原点的偏移量(你可以将原点理解为PDF左上角的那个点)。xy 方向都加了安全偏移量
* w、h 表示内容的宽度和高度
*/
this.pdf.addImage(imgData,
'JPEG',
safeAreaOffsetLeft,
safeAreaOffsetTop + positions[count++],
pdfContentWidth,
pdfContentHeight,
);
// 设置填充的颜色
this.pdf.setFillColor('#ffffff');
// 在每个 PDF 页面的页头绘制并填充一个矩形
this.pdf.rect(0, 0, this.pdf$width, safeAreaOffsetTop, 'F');
/**
* 打个比方:PDF 的高度是 1000,安全区域的高度是 800(四周要预留空白)。
* 此时,页面只绘制了500,剩余的300是因为后面的元素是跨页了,所以要留到下一个页面绘制。
* 所以此时这剩余的 300 是不能展示任何内容的,所以通过 rect() 将这部分进行遮罩。
* 注意,最后一页时不需要遮罩的,因为内容绘制到这里就全部绘制完了,所以无需遮罩。
*/
if (count < positions.length) {
const contentHeight = positions[count - 1] - positions[count];
this.pdf.rect(
0,
safeAreaOffsetTop + contentHeight,
this.pdf$width,
// 页面底部的这个遮罩,从绘制的开始位置开始一直到页面最底部,
// 只需要保证这个高度足够大就行,不需要精确到某个数值(你也可以填99999)。它不会绘制到下一页。
this.pdf$height - contentHeight,
'F',
);
}
// 是否显示页数
if (this.pdfOpts.showPageNumber) {
// 设置字体颜色
this.pdf.setTextColor('#b3b3b3');
// 设置字体大小
this.pdf.setFontSize(12);
// 要打印的内容(不要使用中文,中文会乱码)
this.pdf.text(
`———————— ${count}/${positions.length} ————————`,
this.pdf$width / 2,
this.pdf$safeAreaHeight + safeAreaOffsetTop * 1.5,
{ align: 'center', baseline: 'middle' },
);
}
this.pdf.addPage();
} while(count < positions.length);
this.pdf.deletePage(count + 1);
this.pdf.save(this.fileName);
return Promise.resolve();
}
// 验证节点是否是有效的节点
validateNode(node: HTMLElement) {
if (this.ignoreTagNames.has(node.nodeName.toLowerCase())) return false;
if (this.ignoreNodes.has(node)) return false;
const style = window.getComputedStyle(node);
const display = style.getPropertyValue('display');
const opacity = style.getPropertyValue('opacity');
if (display === 'none' || opacity === '0') return false;
return true;
}
indivisibleNode(node: HTMLElement) {
let { nodeType, nodeName, childElementCount } = node;
return nodeType === 3 ||
childElementCount <= 0 ||
nodeName.toLowerCase() === 'tr' ||
nodeName.toLowerCase() === 'th' ||
node.getAttribute('data-minimum-unit-pdf');
}
/**
* 对指定节点下的所有子节点进行遍历,并返回一个数组,数组用来记录
* 计算每个 PDF 页面的内容最底部的那个节点距离最外层节点的 offsetTop。这个点将被记录到 pos 数组中。用于 PDF 分页打印
* 遍历节点的目的是为了避免生成 PDF 时从元素的中间位置截断。
*/
traversNode(node: any, pdf$height: number) {
const _this = this;
const pos = [0];
// 最外层节点,最初的那个节点。
const node$outermost = node;
// 原点坐标。
const origin = { x: 0, y: 0 };
// 第一次计算时,使用最外层的那个节点相对文档的垂直方向的偏移量。
// 每当 pos 更新世,将使用当前节点的下一个兄弟节点的文档坐标来更新 origin。
origin.y = getElementOffsetTop(node$outermost);
function completeUnitOfWork(unitOfWork: any) {
// 验证该节点是否应该被忽略。被忽略的元素以及该元素下的所有子节点都将会被忽略
let node = unitOfWork;
while (!_this.validateNode(node)) {
while (!node.nextElementSibling) {
node = node.parentNode;
if (node === node$outermost) return;
}
node = node.nextElementSibling;
}
let offsetTop = getElementOffsetTop(node);
let height = node.offsetHeight;
// 计算出当前节点与 origin 的偏移量
let dist = offsetTop - origin.y;
if (dist > pdf$height) {
// 表示这个节点已经不在当前 PDF 页面了。
// 通过 completeWork() 得出此 PDF 页面的结束位置(也是下一页的开始位置)
completeWork(node);
} else if (dist + height >= pdf$height) {
// 表示当前页面是一个跨 PDF 的页面。
if (!_this.indivisibleNode(node)) {
// 如果该节点有子节点,我们需要对该节点下的子节点进行查看
completeUnitOfWork(node.firstElementChild);
} else {
completeWork(node);
}
} else {
// 当前这个节点完全属于这个 PDF 页面,不存在跨页。
// 此时就不需要对它的子节点进行计算了。
while (!node.nextElementSibling) {
node = node.parentNode;
if (node === node$outermost) return;
}
completeUnitOfWork(node.nextElementSibling);
}
}
function completeWork(node: any) {
let offsetTop;
let height;
let dist;
/**
* 查找该节点的上一个相邻的兄弟节点,
* 如果该节点是父节点下的第一个子节点,则查找父节点的上一个相邻的兄弟节点,
* 同时,需要判断该节点是否跨页,只有不夸页的节点才满足条件。
* 存在这样一种布局情况,水平方向布局时,我们需要验证这个 position 上是否存在其他的节点。
*/
do {
while (!node.previousElementSibling) {
node = node.parentNode;
if (node === node$outermost) return;
}
// 查找节点相邻的上一个兄弟节点
node = node.previousElementSibling;
offsetTop = getElementOffsetTop(node);
height = node.offsetHeight;
dist = offsetTop - origin.y;
while (dist + height > pdf$height && !_this.indivisibleNode(node)) {
node = node.lastElementChild;
offsetTop = getElementOffsetTop(node);
height = node.offsetHeight;
dist = offsetTop - origin.y;
}
// 如果条件满足,证明该节点跨页,所以继续循环遍历。
// 判断这个位置是否是一个有效的节点
} while (dist + height > pdf$height || !_this.validateNode(node))
pos.push(pos[pos.length - 1] + dist + height + 1);
origin.y = offsetTop + height;
while (!node.nextElementSibling) {
node = node.parentNode;
if (node === node$outermost) return;
}
completeUnitOfWork(node.nextElementSibling);
}
completeUnitOfWork(node.firstElementChild);
return pos;
}
}
function getElementOffsetTop(element: HTMLElement) {
let offsetTop = element.offsetTop;
let parent: any = element.offsetParent;
while (parent) {
offsetTop += parent.offsetTop;
offsetTop += parseInt(getComputedStyle(parent).getPropertyValue('border-top-width')) | 0;
parent = parent.offsetParent;
}
return offsetTop;
}
export default PrintPDF;
使用
import React, { memo, useEffect} from 'react'
import PrintPDF from '@/utils/printPDF';
function App() {
useEffect(() => {
const ele = document.getElementById('myform')!;
const printPDF = new PrintPDF({ element: ele, fileName: 'test' });
printPDF.print();
}, []);
return (
<div id="myform">...</div>
);
}
export default memo(App);
参考资料
- html2canvas: html2canvas.hertzen.com/configurati…
- jspdf: artskydj.github.io/jsPDF/docs/… 欢迎大家留言指正