做一个项目需要实现浏览器端pdf的导出功能,在此记录一下整个实现过程以及遇到的一些坑:)
当然,解决这个问题有以下几个步骤:
- 确定要导出的dom元素
- 将dom元素转化成canvas( 使用html2canvas库)
- 将canvas转化成图片jpeg,png等都可以
- 将图片导出pdf (使用jspdf库)
确定要导出的dom元素
如果是原生写法可以直接使用document.getElementById来获取,如果是用vue或者react可以设置要ref, 此处我使用的是react框架
const reportRef = useRef();
// canvasEle 为需要获取的dom元素
const canvasEle = reportRef.current
将dom元素转化成canvas
这里其实有几个坑:
- 如果导出的dom元素里存在svg,则需要首先把所有dom中的svg先转化成canvas,否则svg部分会变成空白,svg转canvas 可以使用canvg库来操作
// 此处封装了一个转化函数
import Canvg from 'canvg';
const svg2Canvas = (domEle: HTMLElement, cb: (node: any) => void) => {
// 在临时div上将svg都转换成canvas,并删除原有的svg节点
const svgElem = domEle.querySelectorAll("svg");
svgElem.forEach(async (node: any) => {
const { parentNode } = node;
if (cb && typeof cb === 'function') {
cb(node)
}
const svg = node.outerHTML.trim();
const canvas = document.createElement('canvas');
const width = parentNode.clientWidth
const height = parentNode.clientHeight
const ctx = canvas.getContext('2d');
const v = Canvg.fromString(ctx, svg,
{
ignoreMouse: true, // 不理会鼠标事件
ignoreAnimation: true, // 不理会动画
});
// 保持canvas大小和原来的svg一致
v.resize(width, height);
// 将svg替换成canvas
v.render();
// 拷贝位置的样式
if (node.style.position) {
canvas.style.position += node.style.position;
canvas.style.left += node.style.left;
canvas.style.top += node.style.top;
}
parentNode.removeChild(node);
parentNode.appendChild(canvas);
});
return domEle
}
// svg2Canvas 使用方式
const tempEle = svg2Canvas(
cloneEle,
(node) => {
const circleTrailColor = '#f5f5f5'
const circleTrailPath = '#20486c'
/*
此处注意!!!
我们这里是使用到了ant-design的Progress进度条组件,
Progress中设置的颜色是通过class来设置的。
这会导致颜色无法导出,
因为canvas是不会带class的样式的,
我们需要把颜色写到stroke才会正常显示。
*/
if (node.className.baseVal === 'ant-progress-circle') {
node.querySelector('.ant-progress-circle-trail').style.stroke = circleTrailColor
node.querySelector('.ant-progress-circle-path').style.stroke = circleTrailPath
} else {
/*
此处的处理是由于我们使用了阿里的字体图标,字体图标使用的是use标签如下所示:
<use xlink:href="#iconfendianbu"></use>
这个use标签下面的元素实际上是引用symbol模板的元素
<symbol id="iconfendianbu" viewBox="0 0 1024 1024"></symbol>
这会导致无法正常导出元素,所以我们需要把use标签转化成真正的svg元素
注意在转化的时候一定要使用cloneNode()方法,否则symbol下的svg元素会被挂载上去,会影响到其他使用该图标的地方无法正常显示
*/
const use = node.querySelector('use')
if (use) {
node.style.fill = circleTrailPath
node.setAttribute('viewBox', '0 0 1024 1024')
const href = use.getAttribute('xlink:href')
const id = href.replace('#', '')
const svgSymbolEle = document.getElementById(id)
node.removeChild(use)
const path = svgSymbolEle.querySelectorAll('path')
path.forEach(pathNode => {
const clonePathNode = pathNode.cloneNode(true)
node.appendChild(clonePathNode)
})
}
}
})
// 设置放大倍数,处理画布导出图片模糊的问题
const scale = 2.5
const contentWidth = parseInt(tempEle.scrollWidth)
const contentHeight = parseInt(tempEle.scrollHeight)
// 获取到canvas元素
const canvas = await html2canvas(tempEle, {
dpi: window.devicePixelRatio * scale,
scale, // 放大倍数
width: contentWidth,
heigth: contentHeight,
useCORS: true // 【重要】开启跨域配置
})
将canvas转化成图片jpeg
这一步骤比较简单,只是在之前生成的canvas上调用toDataURL()方法即可
const pageData = canvas.toDataURL('image/jpeg')
将图片导出pdf
此处需要使用jspdf库来操作
// 封装了单页导出的方法,直接调用即可
exportPdfOnePage({
pageData,
contentWidth,
contentHeight,
fileName,
scale
})
// 这里使用单例模式异步导入jspdf库,直接导入会导致项目启动报错
const importJsPDF = () => {
let jspdf: any = null
return async () => {
if (!jspdf) {
const { default: JsPDF } = await import('jspdf')
jspdf = JsPDF
}
return jspdf
}
}
export const getJsPDF = importJsPDF()
// 单页导出psf
export const exportPdfOnePage = async (options: any) => {
const {
pageData,
contentWidth,
contentHeight,
fileName,
scale
} = options
// 设置pdf的尺寸,pdf要使用pt单位 已知 1pt/1px = 0.75 pt = (px/scale)* 0.75
const pdfX = (contentWidth) * 0.75
const pdfY = (contentHeight + 100) * 0.75// 为底部留白
// 设置内容图片的尺寸,img是pt单位
const imgX = pdfX;
const imgY = (contentHeight) * 0.75; // 内容图片这里不需要留白的距离
// 初始化jspdf 第一个参数方向:默认''时为纵向,第二个参数设置pdf内容图片使用的长度单位为pt,第三个参数为PDF的大小,单位是pt
const JsPDF = await getJsPDF()
const pdf = new JsPDF('', 'pt', [pdfX, pdfY])
pdf.addImage(pageData, 'jpeg', 0, 0, imgX, imgY)
pdf.save(`${fileName}.pdf`)
}
// 多页导出pdf
export const exportPdfMutiPage = async (options: any) => {
const {
canvas,
contentWidth,
contentHeight,
fileName,
} = options
const a4Width = 592.28
const a4Height = 841.89
// 一页pdf显示html页面生成的canvas高度;
const pageHeight = contentWidth / a4Width * a4Height
// 未生成pdf的html页面高度
let leftHeight = contentHeight
// pdf页面偏移
let position = 0
// a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
const imgWidth = a4Width
const imgHeight = a4Width / contentWidth * contentHeight
const pageData = canvas.toDataURL('image/jpeg', 1.0)
const JsPDF = await getJsPDF()
const pdf = new JsPDF('', 'pt', 'a4')
// 有两个高度需要区分,一个是html页面的实际高度,和生成pdf的页面高度(841.89)
// 当内容未超过pdf一页显示的范围,无需分页
if (leftHeight < pageHeight) {
pdf.addImage(pageData, 'JPEG', 0, 0, imgWidth + 20, imgHeight)
} else {
while (leftHeight > 0) {
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth + 20, imgHeight)
leftHeight -= pageHeight
position -= 841.89
// 避免添加空白页
if (leftHeight > 0) {
pdf.addPage()
}
}
}
pdf.save(`${fileName}.pdf`)
}
其他的坑
- 在所有的代码都写完之后发现一个问题,就是偶尔导出的pdf上半部分会留空白,截图不完整,而且很多时候留的空白距离还不一样,后来才发现,其实是滚动条的问题,因为html2canvas截图的时候,是根据body从上往下截图的,所以后来做了一个处理,把滚动条跳动到最顶端
// 解决 canvas 截图顶部可能留空白的问题
window.pageYoffset = 0;
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
canvasEle.scrollLeft = 0;
完整功能代码
其中使用的svg2Canvas, exportPdfOnePage这两个函数,可以在上文中找到代码,此处不重复粘贴。
try {
// 增加页面渲染所用的时间
setTimeout(async () => {
message.info('正在导出,请稍后。')
const timestamp = (new Date()).getTime();
const canvasEle: HTMLElement = reportRef.current
if (!canvasEle) return
// 解决 canvas 截图顶部可能留空白的问题
window.pageYoffset = 0;
document.documentElement.scrollTop = 0;
document.body.scrollTop = 0;
canvasEle.scrollLeft = 0;
const cloneEle = canvasEle.cloneNode(true)
cloneEle.style.position = 'absolute'
cloneEle.style.top = '2000px'
cloneEle.style['max-width'] = '1600px'
try {
const fileName = `导出pdf_${timestamp}`
document.body.appendChild(cloneEle);
const tempEle = svg2Canvas(
cloneEle,
(node) => {
const circleTrailColor = '#f5f5f5'
const circleTrailPath = '#20486c'
if (node.className.baseVal === 'ant-progress-circle') {
// eslint-disable-next-line no-param-reassign
node.querySelector('.ant-progress-circle-trail').style.stroke = circleTrailColor
// eslint-disable-next-line no-param-reassign
node.querySelector('.ant-progress-circle-path').style.stroke = circleTrailPath
} else {
const use = node.querySelector('use')
if (use) {
node.style.fill = circleTrailPath
node.setAttribute('viewBox', '0 0 1024 1024')
const href = use.getAttribute('xlink:href')
const id = href.replace('#', '')
const svgSymbolEle = document.getElementById(id)
node.removeChild(use)
const path = svgSymbolEle.querySelectorAll('path')
path.forEach(pathNode => {
const clonePathNode = pathNode.cloneNode(true)
node.appendChild(clonePathNode)
})
}
}
})
// 设置放大倍数,处理画布导出图片模糊的问题
const scale = 2.5
const contentWidth = parseInt(tempEle.scrollWidth)
const contentHeight = parseInt(tempEle.scrollHeight)
const canvas: HTMLCanvasElement = await html2canvas(tempEle, {
dpi: window.devicePixelRatio * scale,
scale, // 放大倍数
width: contentWidth,
heigth: contentHeight,
useCORS: true // 【重要】开启跨域配置
})
document.body.removeChild(cloneEle);
const pageData = canvas.toDataURL('image/jpeg')
// 导出pdf文件
exportPdfOnePage({
pageData,
contentWidth,
contentHeight,
fileName,
scale
})
} catch (err) {
document.body.removeChild(cloneEle);
console.log(err)
}
setExportLoading(false)
}, 500)
} catch (err) {
console.log(err)
setExportLoading(false)
}