做一個項目需要實現瀏覽器端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)
}