记一些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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容