背景
最近有个项目要求把业务数据做成可自定义配置的模块,并可以将所有模块导出图片和pdf,为了攻克后两个难点,google了几个方案,并实践总结了其中几个稍微好点的方案,总体效果还行,但到最后还是遇到了一些瓶颈暂时无法突破。
导出图片
方案一(html2canvas导出图片)
html2canvas库可以将选定html元素绘制成canvas,再通过canvas的toDataUrl方法转成data-uri再创建a标签下载。
Vue.prototype.$getPrintElement = async function (el, imageType = 'jpeg', name = 'download_file') {
if (!el) return
let scale = window.devicePixelRatio || 2
let logging = process.env.NODE_ENV === 'development'
const opts = {
scale, // 缩放比例,提高生成图片清晰度,推荐根据浏览器dpr决定,默认是2
useCORS: true, // 允许加载跨域的图片,需要后端对图片资源获取设置允许跨域,不然不生效
allowTaint: false, // 允许图片跨域(会污染图片导致无法使用toDataURL方法)
logging // 日志开关
}
/**
* 在本地进行文件保存
* @param {string} dataUrl canvas转换后的dataUrl
*/
const saveFile = function (dataUri, filename) {
let aTag = window.document.createElement('a')
aTag.href = pageUrl
aTag.download = filename
window.document.body.appendChild(aTag)
aTag.click()
aTag.remove()
}
/**
* 获取mimeType,让a标签可以直接对资源进行下载
* @param {string} type the old mime-type
* @return the new mime-type
*/
const _fixType = function (type) {
type = type.toLowerCase().replace(/jpg/i, 'jpeg')
let r = type.match(/png|jpeg|bmp|gif/)[0]
return 'image/' + r
}
let canvas = await html2Canvas(el, opts)
let pageUrl = canvas.toDataURL(`image/${imageType}`, 1.0)
pageUrl = pageUrl.replace(_fixType(imageType), 'image/octet-stream')
// 文件名
let filename = name + Date.now() + '.' + imageType
// 导出图片
saveFile(pageUrl, filename)
}
但是实测用toDataURL方法生成的url过长的话,a标签下载会失败,应该是超过了长度限制,所有我们尝试用另外一种方式。google了一番发现还有一种toBlob的方法,就是将canvas转成blob对象,再通过URL.createObjectURL创建一个指向该blob对象的字符串。改写下saveFile方法,因为创建的字符串已经集成了file对象或blob对象的类型,所以不需要再对类型进行兼容改写。
/**
* 在本地进行文件保存
* @param {Object} blob 回调的blob对象
*/
let saveFile = function (blob) {
const Url = window.URL || window.webkitURL
let pageUrl = Url.createObjectURL(blob)
let aTag = window.document.createElement('a')
let filename = name + '_' + new Date().getTime() + '.' + imageType
aTag.href = pageUrl
aTag.download = filename
window.document.body.appendChild(aTag)
aTag.click()
Url.revokeObjectURL(pageUrl) // 下载成功后清空创建的引用
aTag.remove()
}
let canvas = await html2Canvas(el, opts)
canvas.toBlob(saveFile)
toBlob方法生成的图片虽然无url长度限制,但是在实际应用过程中,如果页面的内容过多,例如页面太长,会导致导出的图片一部分内容丢失甚至出现连续的空白像素,该问题暂时还没找到解决方案。
方案二(Puppeteer的截图服务)
Puppeteer是一个由Google官方维护的开源node库,它提供了一个高级API来通过DevTools协议控制Chromium。除了截图和导出pdf,还提供了多种好玩的api,例如爬虫和自动化测试等,是个非常有前景的开源项目。
具体API和介绍请参考官网: https://pptr.dev/
需要注意的是,Puppeteer依赖Chromium,这套微服务最终是要部署到服务器单独运行或者在docker中运行,如果服务器系统是linux,安装Chromium并在docker中运行起来会有点棘手,具体解决方案可以参考官方的troubleshooting和这篇文章
第二个截图方案用到Puppeteer的screenshot。需要用node创建一个http-server,把puppeteer浏览到的网页内容生成一个buffer再放回给前端下载。
- node端代码
const express = require('express')
const app = express()
const puppeteer = require('puppeteer')
async function captureScreen = () => {
// 启动puppeteer并创建浏览器实例
let browser = await puppeteer.launch({
// 在docker中运行需要添加这三个参数
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-setuid-sandbox'],
// 禁用headless
headless: false
})
// 新建页面
let page = await browser.newPage()
// 设置视口宽高
await page.setViewport({width: 980, height: 1080})
// page.goto配置
const pageConfig = {
waitUntil: 'networkidle0', // 等待500ms无网络连接
timeout: 120000
}
// 开始页面navigation
await page.goto('https://google.com', pageConfig)
// 再次等待1s
await page.waitFor(1000)
// 样式控制,为页面添加额外样式
await page.addStyleTag({ content: '.style-tag{display:none}' })
// 截图方法配置
const ssConfig = {
type: 'png', // 生成的格式,jpeg和png,默认png
fullPage: true, // 是否全屏
encoding: 'binary', // 编译格式,base64和binary,默认binary
}
// 开始截图
let data = await page.screenshot(ssConfig)
关闭浏览器
await browser.close()
return data
}
app.get('/screenshot', (req, res) => {
captureScreen()
.then(data => {
res.set({ 'Content-Type': `application/png`, 'Content-length': data.length })
res.send(data)
})
})
- 前端代码
let res = await axios.get(
'/screenshot',
{
responseType: 'arraybuffer',
headers: {
'Accept': 'application/png'
}
}
)
let data = res.data
if (data) {
const Url = window.URL || window.webkitURL
// 放回的buffer转成blob对象
let blob = new Blob([data], { type: 'application/png' })
let link = document.createElement('a')
let url = Url.createObjectURL(blob)
link.href = url
link.download = 'filename.png'
link.click()
Url.revokeObjectURL(url)
}
通过这种方法可以让服务器去截图返回给前端下载,puppeteer也提供了其他navigation的拓展方法,例如截取范围clip、浏览行为evaluate等,可自行根据业务需求加以拓展。
但在实践中得知如果图片太大,下载下来的图片还是会严重失真甚至整张图为黑色,解决方案待研究。
导出pdf
导出pdf的功能基本在上述两个方案的前提下进行的添加和调整。
方案一(html2canvas + jspdf)
该方案用html2canvas把需要的dom节点绘制成canvas并转成dataUrl,再通过jspdf导出整张图
Vue.prototype.$getPDF = async function (el, title) {
if (!el) return
let scale = window.devicePixelRatio || 2
let logging = process.env.NODE_ENV === 'development'
const opts = {
scale, // 缩放比例,提高生成图片清晰度
useCORS: true, // 允许加载跨域的图片
/**
* 允许图片跨域(会污染图片导致无法使用toDataURL方法)
* @TODO 除非后端返回的image_url为base64,这一点没测试过
*/
allowTaint: false,
logging // 日志开关
}
let canvas = await html2Canvas(el, opts)
// 获取canvas的真实宽高
let contentWidth = canvas.width
let contentHeight = canvas.height
// 图片的宽高,72dpi下,A4纸像素宽为595,像素高为842
let imgWidth = 595
let pdfHeight = 842
// 计算canvas在一页pdf里的高度
let pageHeight = (contentWidth / imgWidth) * pdfHeight
// 计算缩放后的图片高度
let imgHeight = (imgWidth / contentWidth) * contentHeight
// 定义剩余高度
let leftHeight = contentHeight
// 记录y轴坐标
let position = 0
let pageUrl = canvas.toDataURL('image/jpeg', 1.0)
let PDF = new JsPDF('', 'pt', 'a4')
PDF.addImage(pageUrl, 'JPEG', 0, 0, imgWidth, imgHeight)
if (leftHeight < pageHeight) {
// 当内容未超过pdf一页显示的范围,无需分页
PDF.addImage(pageUrl, 'JPEG', 0, 0, imgWidth, imgHeight)
} else {
while (leftHeight > 0) {
// 添加分页,每插入一页,图片向上移动一页位置
PDF.addImage(pageUrl, 'JPEG', 0, position, imgWidth, imgHeight)
leftHeight -= pageHeight
position -= pdfHeight
if (leftHeight > 0) {
PDF.addPage()
}
}
}
// 最后导出pdf
PDF.save(title + '.pdf')
}
但是该方案有三个问题,一是导出的pdf文件很大,内容多的时候甚至超过了20MB;二是分页会导致内容被截断,图片、canvas和文字都会被截断,解决办法暂时没找到,我想如果要应对内容和嵌套过多的DOM结构,解决起来应该不简单;三是由于pdf的每一页都是一整张图片,如果想做能选择里面的文字,在结构如此复杂的情况下,是基本没什么办法的。所以我们优先考虑其他更为方便一点的方案。
方案二(Puppeteer生成pdf)
上面已经提及了Puppeteer生成截图的功能,这里生成pdf也是Puppeteer的功能之一,实现起来更为方便。
- node端代码
const genPDF = async () => {
let browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-setuid-sandbox'],
// 生成pdf需要在headless模式下运行
headless: true
})
let page = await browser.newPage()
const pageConfig = {
waitUntil: 'networkidle0',
timeout: 120000
}
await page.goto('https://google.com', pageConfig)
await page.waitFor(1000)
await page.addStyleTag({ content: '.style-tag{display:none}' })
// pdf配置
const pdfConfig = {
// A4纸格式
format: 'A4',
// 打印背景图片
printBackground: true
}
let pdf = await page.pdf(pdfConfig)
// 关闭浏览器
await browser.close()
return pdf
}
app.get('/pdf', (req, res) => {
genPDF()
.then(pdf => {
res.set({ 'Content-Type': `application/pdf`, 'Content-length': pdf.length })
res.send(pdf)
})
})
- 前端代码
let res = await axios.get(
'/pdf',
{
responseType: 'arraybuffer',
headers: {
'Accept': 'application/pdf'
}
}
)
let pdfData = res.data
if (pdfData) {
const Url = window.URL || window.webkitURL
let blob = new Blob([pdfData], { type: 'application/pdf' })
let link = document.createElement('a')
let url = Url.createObjectURL(blob)
link.href = url
link.download = `file-name.pdf`
link.click()
Url.revokeObjectURL(url)
}
Puppeteer应该是控制了chrome的打印模式来实现pdf的导出,现在用这种方式生成的pdf已经小了很多,内容多的时候也只有几MB,并且不用自己计算分页了,里面的文字已经不会被截断,放不下一页会自动往下一页添加文字,并且可以选择里面的文字。但是问题还是有的,就是图片和canvas依然会被截断,特别是表格里面有图片还有文字的情况下,图片都是直接被分页忽略的,尝试了几个方法都没解决。既然Puppeteer是控制了打印模式生成pdf,那么css的打印规则似乎可以用来解决这个问题,暂时没研究透,到时再补充。
最后列几篇对理解有帮助的文章,感谢这些文章的原创作者的贡献,让我们在踩坑的路上少走很多弯路。
https://juejin.im/post/5bbc96785188255c72286403
https://juejin.im/post/5ca1dc0251882543d569e075