你导出的PDF还是一大张图吗?

2,980 阅读16分钟

距离上次更文已是一个半月了=_=

要做什么

随着浏览器里能做的事越来越多,CSS的逐步升级,在网页上制作一些漂亮的报告、文章也是一个不错的选择,但这些产物的传播会因种种因素受限,比如离线环境直接歇菜,比如需要将产物归档进另一个系统(云盘、微信)等等,分享一个链接并不靠谱,因为这个链接随时可能会因为业务变动失效,那么导出为图片或PDF就成了一个主流的选择。

怎么实现

本文将介绍多种导出方案,逐步进阶,你不一定要全部用过,但你的军备库里应该有一份方案。

HTML2Canvas

首先借助于Canvas的强大能力,一张自由度极高的画布允许我们制作丰富的内容,再借由canvas.toDataURL()返回一个数据URL,该URL包含由类型参数指定的格式的图像(默认为png)。 返回的图像分辨率为96dpi。

一点科普:

toDataURL API 接受两个参数:

  1. type 图像格式,默认值是image/png,在导出的数据URL开头你就能看见它,如果和你设定的不一样,那说明不支持这种格式,会自动fallback为image/png,别担心,总是能出图的。

  2. encoderOptions 图像质量,这在指定图片格式为 image/jpeg 或 image/webp的情况下,可以从 0 到 1 的区间内选择图片的质量。如果超出取值范围,将会使用默认值 0.92。是的,数字越大越清晰,同时Size越大,如果你想适度压缩,建议选在0.6或以上,再小真看不清了。

或许你早已知道业内大名鼎鼎的html2canvas这个库了,接受一个DOM元素,返回一个画好的Canvas,通常这就是一个可用方案了,然后再由你自己决定导出格式和图像质量。

但,它的缺陷你了解过吗?

或者说,一些使用细节。

1. 图片跨域

首先给html2canvas撇清一点,这个问题是canvas本身的问题。如果你想把一个图片画到canvas并导出,那么当它的src是一个网络地址,受限于你所熟知的经典浏览器安全策略——跨域,就无法正常绘制了。

由于在 <canvas> 位图中的像素可能来自多种来源,包括从其他主机检索的图像或视频,因此不可避免的会出现安全问题。

尽管不通过 CORS 就可以在 <canvas> 中使用其他来源的图片,但是这会污染画布,并且不再认为是安全的画布,这将可能在 <canvas> 检索数据过程中引发异常。

如果从外部引入的 HTML <img> 或 SVG <svg> ,并且图像源不符合规则,将会被阻止从 <canvas> 中读取数据。

在"被污染"的画布中调用以下方法将会抛出安全错误:

  • 在 <canvas> 的上下文上调用getImageData()
  • 在 <canvas> 上调用 toBlob()
  • 在 <canvas> 上调用  toDataURL()

这种机制可以避免未经许可拉取远程网站信息而导致的用户隐私泄露。

参考自MDN

通常我们这样绘制一个图片到canvas:

const canvas = document.createElement('canvas');
const context = canvas.getContext('2d'); // 拿到canvas上下文

// 创建一个图片对象,或许你还是第一次见这个new Image()的写法?
const img = new Image(); 
img.src = '网络地址比如://xxxxx.jpg';
// 关键的一步,为其设置允许跨域
img.crossOrigin = '';
img.onload = function () {
    context.drawImage(this, 0, 0);
    context.getImageData(0, 0, this.width, this.height);
};

图片对象除了你所熟知的src属性外,还有一个crossOrigin属性,这个字段有两个值可选:anonymoususe-credentials,事实上除了use-credentials以外的任何字符都将会被解析为anonymous,包括空字符串,anonymous表示服务端在返回图片地址时,header里无需添加任何非匿名信息,也就是说只要图片资源,什么身份校验Cookie信息啥的都不要。此外,服务端返回图片需要时间渲染需要时间,故而Image对象还提供了一个onLoad方法,这里表示图像加载完毕,此时去读取图片像素信息才是符合预期的。

html2canvas不需要你操作太多,配置了allowTaint:true即可;

浏览器要做的事就是这些,同时别忘了跨域的经典服务端策略:图片服务器给响应Header加上合适的Access-Control-Allow-Origin,通常在一些对象存储云服务商的web页面都有快捷的设置。

2. 高清缩放

先来看一段代码:

const dom = document.querySelector('.container');
const imgHeight = dom.offsetHeight // 获取DOM高度
const imgWidth = dom.offsetWidth // 获取DOM宽度 
const scale = window.devicePixelRatio // 获取设备像素比 
html2canvas(dom, {
    allowTaint:true,
    scale,
    width: imgWidth, 
    height: imgHeight 
    })

由于最终导出的非矢量图片的放大会失真,为了导出产物的清晰,我们通常会想到在浏览器中先把元素尺寸都放大,再保存为图,实际操作上,就是把canvas的宽高先放大,然后再把元素也放大画上去。

scale顾名思义是缩放,为什么要缩放?

这里引入了一个重要的概念,devicePixelRatio,或许你听过苹果的Retina屏幕为什么更清晰,因为他用4个物理像素块渲染1个逻辑像素,也就是你代码里定义的1px在它的屏幕上占据了2行,此设备的devicePixelRatio就等于2,这样可以渲染出0.5px的线,整个画面会显得更加精细。当然,或许你从未设置过这个scale,那是因为默认值1,而你公司电脑的普通屏幕通常就是1:1渲染,所以视力倍儿棒的你可以看到很多锯齿,不信现在打开控制台看看window.devicePixelRatio是不是1。

不缩放会怎样?

如果你尊贵的用户使用了高清屏幕,那么这里由于画布尺寸比元素尺寸小,显然导出图片只是左上角的一部分。

当然了如果你要继续放大,记得把画布宽高和scale值同时放大,放大倍数建议选择2的n次方,这样能更好的适配高清屏。

3. 不支持的CSS

或许你以为除了图片资源会跨域,其他的一些div,span都能原样画上去,一些CSS属性也是不支持的,列举一些常见的:

  • background-clip值为text,似乎没什么方法能做出同样效果
  • box-shadow,阴影效果,好在这个影响不大
  • object-fit, 图片的内部对齐模式,好在你可以用其他的CSS属性组合出一样的效果

关于更全面的CSS支持列表查看文档

常用的CSS属性基本都是OK的,所以你或许也不知道CSS的支持不是100%。

4. 可视范围

长截图也是一个常见需求,如果所截图元素A在一个容器B里,B的高度小于A,好在你可以使用overflow-y让B元素内的超出内容可滚动,但是在给html2canvas注入参数时请注意,传入B,并不会自动把内容滚动截图,一定要传入A元素本身

JSPDF

关于DOM元素转换成图片的方案,或许不是html2canvas,是其他的一些库,但是原理都是一样,无一例外都是把DOM画在canvas,然后给出一个DataURL,DataURL转成图片文件下载(这部分不讲了,借助于window.URL.createObjectURL方法),或直接放在img的src里渲染出来,所以DOM转图仅以html2canvas为例介绍。

你尊贵的领导又来了:那再给出个PDF吧,咱们常用这个。

你开始分析需求,PDF是一种优秀的文档格式,它很好地解决了“在我电脑上看很漂亮的Word排版怎么去你那看乱七八糟的”这一问题,它锁定了排版格式,通常还不能编辑。说到不能编辑,你又想起了你见过一些PDF,里面的文字不只不能编辑,还不能选中,其实压根是一大张图片啊。

思路来了,前边导出的图我直接放PDF里得了。

jspdf是一个在前端生成PDF的库,它像canvas一样提供一张画布作为一页,你可以在上面添加元素,然后导出为PDF文件下载到本地。这里没有太多HTML原生知识要注意,直接看代码:

import JsPDF from 'jspdf';

html2Canvas(dom, { allowTaint: true }).then((canvas) => {
  // a4纸的正常尺寸是宽592.28,高是841.89
  const pageWidth = 841.89
  const pageHeight = 592.28
  // 设置内容的宽高 
  const contentWidth = canvas.width
  const contentHeight = canvas.height
  // 默认的偏移量 
  let position = 0
  // 设置生成图片的宽高
  const imgCanvasWidth = pageWidth
  const imgCanvasHeight = 592.28 / contentWidth * contentHeight
  let imageHeight = imgCanvasHeight
  let pageData = canvas.toDataURL('image/jpeg', 1)
  // new JsPDF接收三个参数,landscape表示横向,(默认不填是纵向),打印单位和纸张尺寸 
  let PDF = new JsPDF('landscape', 'pt', 'a4')

  // 当内容不超过a4纸一页的情况下
  if (imageHeight < pageHeight) {
    PDF.addImage(pageData, 'JPEG', 20, 20, imgCanvasWidth, imgCanvasHeight)
  } else {
    // 当内容超过a4纸一页的情况下,需要增加一页 
    while (imageHeight > 0) {
      PDF.addImage(pageData, 'JPEG', 20, position, imgCanvasWidth, imgCanvasHeight)
      imageHeight -= pageHeight
      position -= pageHeight
      // 避免添加空白页
      if (imageHeight > 0) {
        PDF.addPage()
      }
    }
  }
  // 保存太简单了
  PDF.save('导出pdf' + '.pdf')
})

new一个PDF对象后,调用addImage把图添加进去,同样是要注意尺寸数据,做一些高度计算,进行分页放置,最后调用save方法直接下载。

事实上JSPDF提供了很多API,addImage是添加图片上去,自然也有添加文字的jsPDF.text(text,x,y,options),还有绘制任意线条的jsPDF.path(lines,style),是的,用代码绘制线条无非还是像svg一样的语法,lines就是一些描述了m,l,c,h信息的数组。

但是很可惜,一个成功的npm库,不仅仅是要提供强大的API,还要易用,虽然JSPDF可以写字,可以画线,可以添加图片,但你会自己用这些API编写代码把DOM原样画上去吗?显然这会是巨大的工作量。html2canvas也正是因为承担了这些工作,所以被广泛使用。 至此,你总算做出了一个PDF发给领导,领导满意的打开文件,图片都在,文字都在,排版也没乱。可是这文字怎么选不中啊?领导像你提出了灵魂拷问:

你给我说下这个和图片有什么区别?

这一问直击内心,其实你自己也早就知道这么做略敷衍。于是,打开JSPDF的文档再看看吧。滚动到文档结尾,你看到一个jsPDF.html()方法,这个名字不由让人一惊,果然,API的使用十分简单,支持传入一个DOM,然后save一个PDF文件出来。

两行代码敲下,一个文件生成,打开一看,全是乱码

乱码这东西别人不知道你还能不懂?数据肯定是对的,但解码的方式不对罢了,你开始找找哪里能指定编码方式,塞个utf-8进去,然而并不这么简单,一个意料之外又是情理之中的原因:

JSPDF是老外开发的,天然支持英文,却没考虑中文

解决方案是需要加载中文字体库,JSPDF提供了一个addFont方法,首先使用addFileToVFS方法加载字体数据,然后使用addFont,但是这里的坑依然太多。

  1. 首先你要自己找到中文字体文件,并是ttf格式,将字体文件名改成全小写;
  2. 在专用字体转换网站上把ttf转换成js文件;
  3. 然后打开这个js文件把font变量的值拷贝出来(到这你就不想干了,因为这个非常长可能会导致电脑卡顿);
  4. 使用pdf.addFileToVFS('字体名字', font)方法导入字体,font就是刚才那个超长的字符串;
  5. 使用pdf.addFont('字体名字', 'hahahaha', 'normal')加载字体为hahahaha
  6. 使用pdf.setFont('hahahaha')使用字体;

但事情远远没完,因为表格还有坑......

表格的尺寸仍无法正常显示,这里非常难受的是你需要导入一个指定字体,而且在你导入了这么多字体后,整个项目的体积将会瞬间增大百倍,或许你认为网络条件下载几十兆不是问题,可是用户的浏览器会爆内存,你的开发环境打包也会爆内存,所以这是一个理论上OK却并不能使用的方案。

window.print()

或许你想起了浏览器的打印功能,直接唤醒系统打印功能,但选择打印机时又可以选择导出PDF,代码实现也非常简单,只要一行window.print()即可实现。

那这么简单的方案,为什么没作为首选呢?

  1. 不美观,唤醒浏览器打印功能窗口,这个窗口未必与你的项目界面风格很搭;
  2. 其次需要用户理解,不是真的打印,这里居然可以导出PDF?
  3. 由于用户的设备不同,浏览器加载效果未必理想,出来的PDF也不一定相同;
  4. 默认全页面打印,无关DOM元素无法剔除;

其实这最后一点是最不能接受的,又是最好解决的,新开一个页面,只包含想打印的元素即可,但这样操作成本又上来了,你可以用代码给用户打开新页面并跳转到指定路由,然后用户再在浏览器打印弹窗中操作。

puppeteer

终极方案。

window.print是样式还原度最好的方式,如果不是弹了个弹窗,简直就是又好用又好写。

如果把这个动作搬到服务端就好了,puppeteer是一个Google Chrome团队开源的Headless Chrome,相当于一个没有界面的浏览器,并提供了API,可以打开新标签页,打开网络地址,导出PDF(我觉得就是基于print),点击元素,输入文本,甚至执行JS代码等等各种模拟人操作浏览器的动作,当然,要在Node环境下运行。

使用非常简单,就是个npm包:

const puppeteer = require('puppeteer')

let browserInstance;

async function start(){
    // 启动一个浏览器
    browserInstance = await openBrowser();
}

async function openBrowser() {
  const launchConfig = {
    headless: true,
  };
  // 将来部署到linux下要开沙盒模式,安全
  if (!isDev()) {
    launchConfig.args = ["--no-sandbox", "--disable-setuid-sandbox"];
  }
  // 启动一个浏览器
  const browser = await puppeteer.launch(launchConfig);
  return browser;
}

async function openPage(url) {
  // 打开一个新标签页
  const page = await browserInstance.newPage();
  // 设置合适的浏览器宽高
  await page.setViewport({ width: 1200, height: 1080 });
  // 访问地址
  await page.goto(url);
  return page;
}

async function page2PDF(page) {
  // 等待一个标记物出现
  await page.waitForSelector(".finished-pdf", {
    timeout: 90000,
  });
  await sleep(2000);
  // 创建一个随机文件名
  const fileName = createUUID(12);
  // 拼出一个PDF文件的生成路径
  const pathStr = path.join(__dirname, "..", `/pdf/${fileName ?? "报告"}.pdf`);
  console.log("开始生成PDF文件", formatToDateTime());
  // 生成PDF文件
  await page.pdf({
    path: pathStr,
    margin: {
      left: "20px",
      right: "20px",
      top: "40px",
      bottom: "40px",
    },
    format: "a4", // A4纸的意思
    scale: 0.75, // 适度缩放
    printBackground: true, // 包含背景
    timeout:90000 // 超时
  });
  console.log("完成生成PDF文件", formatToDateTime());
  return { pathStr, fileName };
}

总体来说API使用非常简单,内存中启动一个浏览器,开一个标签页,使标签页打开某页面,等待页面加载完成,调用pdf方法,配置一些样式参数,生成文件。

需要注意的一些细节:

  1. 结合koa或者express做一个web服务,你可以响应多个创建PDF的任务。Chrome是多标签的,所以合理的做法是开一个浏览器实例就够了,每次需要执行任务就开一个标签页,生成PDF后关掉这个标签页,浏览器实例不要销毁。

  2. 等待页面加载完成,puppeteer可以检测到DOM元素的加载,在react或者vue项目中,通常这个页面的许多DOM节点需要请求到数据后才渲染,所以你要等的其实是所有数据加载完成,这一步需要对前端项目做一点修改,我的做法是:在你的业务逻辑里,例如一个列表的数据请求回来后,结合react或vue的特性,在下一轮tick(vue里的nextTick)给页面加一个看不见的dom作为标记物,我的建议是渲染一个<i>标签,赋予一个特别的class名字例如我的.finished-pdf,并添加到body,display设为none也不影响页面。puppeteer的page.waitForSelector方法顾名思义就是等待一个DOM元素的加载,创建PDF应该在这行代码之后进行比较稳妥。

  3. 字体同样是值得关注的一点,在你的Windows或Mac上是无需操心的,但如果部署到Linux上,Linux通常不保有丰富的字体文件,需要你手动添加,不过别担心,这可简单多了。

  • 准备字体包

    通常需要中英文字体包括 微软雅黑Segoe UI ,Arial,鉴于字重的多样性,请准备齐全boldnormal两种字体文件。 将字体包解压至/usr/share/fonts,所有字体散落在此目录下即可。

  • 编译字体,依次执行这三条命令即可,最后一条是刷新字体缓存的意思。

        mkfontscale
    
        mkfontdir
    
        fc-cache
    
  1. 文字的可编辑性,这时候导出的PDF,文字就是文字,图片就是图片,故而文字可选中也可伴随着PDF视图放大而无损放大,但png图片自然还是会失真,这个是正常现象。特别说明一点,如果你使用了echart等使用了canvas的绘图工具,canvas画布也会被处理成图片,幸而echart提供了svg渲染方式,这个就很棒了,整个图不再是一个canvas画布,而是众多svg元素构成,这在转出PDF后,echart中的线条、面积等仍是矢量,可以无损放大,甚至由于采用svg的text节点渲染文字,echart图里的文字也能在PDF中被选中了。

    同理,你应该得出的结论是,能用svg的就用svg吧!

至此,HTML2PDF的方案基本落地成功,细节优化请根据各位的业务自行设计。

寄语

最后说一点布道者的话,作为一个前端,我们应该庆幸于处在一个前端高速发展的时代,Node的出现让你不用切换语言就能学习后端知识,不要害怕离开浏览器,客户端和服务端都是我们需要了解的环境,不要看到“服务端”三个字就关掉这篇文章,就打消学习的念头,路漫漫其修远兮,吾将上下而求索。