记一些js导出图片与pdf的方案

背景

最近有个项目要求把业务数据做成可自定义配置的模块,并可以将所有模块导出图片和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

你可能感兴趣的:(记一些js导出图片与pdf的方案)