揭秘海报生成技术

科技   2024-09-23 08:37   福建  

1 引言

随着裂变营销策略的兴起,定制化海报分享的需求不断增加。作为开发者,一张背景图+一个二维码的海报合成的需求便会出现在我们的工作中,如下图。

本文给大家介绍海报生成相关知识以及使用中常见的问题。希望能够抛砖引玉,为遇到类似需求或问题的伙伴们提供参考。

2 实现方式

2.1 生成步骤

在用户视角,海报生成像是“截图”,点击生成海报按钮之后,定制化海报便会呈现在屏幕上,再点击保存按钮,海报便会保存在手机相册里。

而在程序内部,还需要开发者做一些其他工作。这里的客户端包含原生和前端,两者在实现原理上类似——首先用画布绘制海报,然后将海报转成图片。在服务端,常用的方案是开启一个无头浏览器,先在浏览器上渲染出海报,然后截图生成图片,将生成的图片下发给前端。

2.2 各端类库

如下图,服务端不同语言、客户端不同系统都有可供直接上手的类库。

简单对三端进行下对比

生成效率海报效果兼容性其他
服务端与服务器性能成正比中等开发成本高
客户端较差维护成本高
前端中等较好复杂排版受限
  • 服务端:在服务端生成海报大部分会选择使用puppeteer等插件,模拟一个浏览器然后渲染海报并截图。服务端的生成效率以及质量强依赖于服务器性能,不过与之对应的客户端的压力也会变小。另外,在查阅资料时,发现一个有趣的实现方式--图片水印。如果是由一个背景和一个二维码拼成的这种简单场景,可以把背景当作图片,二维码当作水印,直接调用第三方的图片水印能力,就能便捷实现海报生成能力。
  • 客户端:客户端可以直接利用设备的CPU和GPU,所以在生成海报的效率上有着天然优势。但是端的维护成本较高,需要各端分别去实现。
  • 前端:前端海报生成的类库多种多样,普通的海报生成需求都能满足,但也有一些跨域、复杂排版受限等问题。笔者作为前端开发,深入学习了前端实现的方案,下面讲一下前端实现及遇到的问题。

2.3 前端实现

前端的实现方案除使用Canvas API纯手写外,还有三类可参考的js库。Canvas API较为底层,上手成本高,海报生成需求真正用此实现的极少,下文一笔带过,重点展开讲一下现有的js库。

2.3.1 Canvas API

Canvas API(画布)用于在网页实时生成图像,并且可以操作图像内容。这种方案是开发者直接在画布上进行海报绘制,然后使用canvas.toDataURL将画布转为图片,如果绘制一些简单的图像还是可以使用的。

 // html中创建Canvas元素
  <canvas id="canvas"></canvas>
 // 获取Canvas元素
  const canvasEl = document.getElementById('canvas');
  // 获取上下文
  const ctx = canvasEl.getContext('2d');
  // 设置填充颜色
  ctx.fillStyle = 'red';
  ctx.fillRect(1001002020);

2.3.2 JS库

常用可以实现海报生成的JS库有三种类型。第一种是以Fabric.js为代表的,通过直接封装底层API实现的。第二种是重写渲染引擎的,代表类库是html2canvas。第三种使用了SVG的foreignObject,常用的库是dom-to-image。

类型实现思路代表类库优点缺点
直接封装Canvas API封装底层APIFabric.js海报可定制使用门槛高
DOM->Canvas重写一套新的渲染引擎html2canvas使用门槛低、内置跨域方案部分css不兼容
DOM->SVG->Canvas使用了SVG的foreignObjectdom-to-image使用门槛低、还原度高不支持跨域

下面依次详细讲一下提及到的代表类库。

Fabric.js

Fabric.js是活跃在github上的明星项目,这个库对canvas进行封装,提供更丰富的图形支持以及事件处理。下面是Fabric.js的用法。

  // html中创建Canvas元素
  <canvas id="canvas"></canvas>
  // 创建一个fabric实例
  let canvas = new fabric.Canvas("canvas");
  // 创建一个矩形对象
  let rect = new fabric.Rect({
      left100//距离左边的距离
      top100//距离上边的距离
      fill"red"//填充的颜色
      width20//矩形宽度
      height20//矩形高度
  });
  // 将矩形添加到canvas画布上
  canvas.add(rect);
 // 在画布上绘制一张图片
  fabric.Image.fromURL('imagePath.jpg'function(img{
     img.set({
         left400,
         top200,
     });
     canvas.add(img);
  });

通过对比不难看出,Fabric.js的简单用法是和原生语法类似的。但当需要海报样式可DIY时,就体现出了Fabric.js的强大之处。如下图,用户可以对图形使用拖动、缩放、旋转、改变大小和形状等操作,可以实现高度定制化的海报。

html2canvas

html2canvas官方是这样介绍的——该脚本允许您直接在用户浏览器上对网页或部分网页进行“截图”。截图基于 DOM,因此可能与实际表示不完全一致,因为它不会制作实际的截图,而是基于页面上可用的信息构建截图。此外,截止到2024年8月,html2canvas库在github已经有30.3k star。

html2canvas使用

html2canvas不同于前两种方式,无需调用绘制API,只需将DOM传入js库提供的方法,便可得到对应的图片。

// html中创建需要绘制的元素
<div id="绘制div">
 <img src="海报图片路径"/>
  <p>海报文本</p>
</div>
import html2canvas from 'html2canvas'
// 获取绘制元素
const el = document.getElementById('绘制div');
// 调用html2canvas方法进行绘制
html2canvas(el).then(function(canvas{
    // 使用toDataURL处理Canvas即可
})
html2canvas原理

虽然使用简单,但是这个库的底层实现是极其复杂的,可以大致理解为参考浏览器渲染原理又实现了一套新的渲染引擎,使用Canvas API将HTML+CSS画出来。大体实现流程如下

重点步骤一:获取节点树

获取节点树用到的方法是parseTree。parseTree的入参就是一个普通的DOM元素,返回值是一个ElementContainer对象,该对象主要包含DOM元素的位置信息(bounds: width|height|left|top)、样式数据、文本节点数据等(只是节点树的相关信息,不包含层叠数据,层叠数据在parseStackingContexts方法中取得)。

解析的方法就是递归整个DOM树,并取得每一层节点的数据。ElementContainer对象大致如下:

{
  bounds: {height: 260, left: 6, top: -100, width: 1440},
  elements: [
    {
      bounds: {left: 6, top: -100, width: 1440, height: 240},
      elements: [
        {
          bounds: {left: 6, top: -100, width: 1440, height: 240},
          elements: [
            {styles: CSSParsedDeclaration, textNodes: Array(1), elements: Array(0), bounds: Bounds, flags: 0},
            {styles: CSSParsedDeclaration, textNodes: Array(1), elements: Array(0), bounds: Bounds, flags: 0},
            ...
          ],
          flags: 0,
          styles: {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
          textNodes: []
        }
      ],
      flags: 0,
      styles: CSSParsedDeclaration {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
      textNodes: []
    }
  ],
  flags: 4,
  styles: CSSParsedDeclaration {backgroundClip: Array(1), backgroundColor: 0, backgroundImage: Array(0), backgroundOrigin: Array(1), backgroundPosition: Array(1), …},
  textNodes: []
}

重点步骤二:渲染离屏Canvas

将节点树遍历得到层叠数据后,将层叠数据渲染到离屏Canvas的过程,是html2canvas最核心的事情,这件事由renderStackContent方法来实现,为了避免渲染过程中流式布局被浮动或定位元素打破布局,renderStackContent使用了CSS层叠布局规则,如下图。

默认情况下,CSS是流式布局,按顺序渲染即可,但如果遇到浮动或定位时,原有的简单布局就会被打破,脱离正常文档流的元素会形成层叠上下文,可以理解为PS中的图层,将这些图层叠在一起,最终绘制出看到的海报。下面的源码可以理解为html2canvas是对CSS层叠布局规则的一个实现。

async renderStackContent(stack: StackingContext) {
    // 1. 最底层是background/border
    await this.renderNodeBackgroundAndBorders(stack.element);
    // 2. 第二层是负z-index
    for (const child of stack.negativeZIndex) {
        await this.renderStack(child);
    }
    // 3. 第三层是block块状盒子
    await this.renderNodeContent(stack.element);
    for (const child of stack.nonInlineLevel) {
        await this.renderNode(child);
    }
    // 4. 第四层是float浮动盒子
    for (const child of stack.nonPositionedFloats) {
        await this.renderStack(child);
    }
    // 5. 第五层是inline/inline-block水平盒子
    for (const child of stack.nonPositionedInlineLevel) {
        await this.renderStack(child);
    }
    for (const child of stack.inlineLevel) {
        await this.renderNode(child);
    }
    // 6. 第六层是以下三种:
    // (1) ‘z-index: auto’或‘z-index: 0’。
    // (2) ‘transform: none’
    // (3) opacity小于1
    for (const child of stack.zeroOrAutoZIndexOrTransformedOrOpacity) {
        await this.renderStack(child);
    }
    // 7. 第七层是正z-index
    for (const child of stack.positiveZIndex) {
        await this.renderStack(child);
    }
}

正因为html2canvas重写了渲染引擎,所以对CSS的支持并不是很友好,如果有较为复杂的样式,需要进行充分的调试。即便如此,html2canvas仍保持每周150w+的下载量,是DOM直接绘制图片领域的霸主。

dom-to-image

dom-to-image是另外一种类型的“截图”工具,同样适用于海报绘制。使用方式和html2canvas大同小异,传入DOM即可生成对应的图片。

dom-to-image使用
// 引入dom-to-image库
import domtoimage from 'dom-to-image';
// 需要转换成图像的DOM节点
const node = document.getElementById('绘制div');
// 使用domtoimage.toPng将DOM节点转换成PNG图像
domtoimage.toPng(node)
    .then(function (dataUrl{
        // 创建一个图片元素并设置src属性为转换后的图像数据URL
        var img = new Image();
        img.src = dataUrl;
      // 将图片添加到文档中
      document.body.appendChild(img);
  })
dom-to-image原理

dom-to-image实现原理要比html2canvas简单的多,直接使用SVG的foreignObject,只需要把DOM放在这个方法里,便可以在SVG绘制出对应的图片,因为SVG是浏览器的标准,所以不用担心此类方法对CSS的支持不友好问题。

需要注意的是,dom-to-image在将DOM绘制成SVG后,也使用了Canvas进行重新绘制。SVG已经是图片,为什么还要再使用Canvas呢,因为SVG方案生成的图片体积很大,包含很多冗余信息,使用Canvas进行重新绘制,可以大大降低图片体积,还能导出想要的图片格式。

其他

  • dom-to-image-more 基于dom-to-image,解决了跨域问题
  • html-to-image 基于dom-to-image,增加了typescript支持
  • modern-screenshot 基于dom-to-image,整合了以上的优化,是个理想的选择
  • Painter.js 适用于小程序端

3 常见问题

3.1 跨域

问题原因

不论用Canvas还是SVG生成海报时,海报中的图片会重新加载再进行绘制,虽然img标签本身不会跨域,但用于绘制时会触发浏览器的限制。

解决方案

  • 请求图片时增加属性img.crossOrigin = 'anonymous',但是这样使用会有一个风险,如果需要canvas绘制的图片在页面中已经加载过一次,图片会被浏览器缓存,当绘制时,设置过的crossOrigin便会失效。

  • 使用html2canvas、dom-to-image-more等库中封装好的内置跨域能力,大多实现原理也比较简单,为了避免浏览器缓存,会在资源请求时附带时间戳。

3.2 图片白屏(html2canvas)

问题原因

当使用html2canvas时,如果先将生成页滑动到底部再生成海报,就会出现图片白屏问题,排除跨域问题、资源加载问题,发现是触发了html2canvas天然bug。

正常情况下,html2canvas会从顶部开始绘制传入的DOM,但当同时满足以下三点时便会出现保存在本地的海报有白屏情况。

  • 海报生成页超出一屏,也就是y轴有滚动条
  • 滚动条发生滚动
  • 预期绘制的海报是通过弹窗形式展现在屏幕中间的

产生的原因在源码中找到了答案,在renderCanvas方法中进行了下面操作:

this.ctx.translate(-options.x + options.scrollX, -options.y + options.scrollY);

在绘制时,画布的宽度高度默认为DOM的宽度高度,问题就出现在y轴的坐标上。

y轴起始坐标=-options.y + options.scrollY,其中的y默认值为0,scrollY默认值是window.pageYOffset,也就是默认绘制的y轴坐标为已经滚出视窗的y轴的高度。所以实际截图时便会出现下面这种情况。

解决方案

  • 直接使用window.scrollTo(0, 0),但底部页面会发生滚动,如果在没有过高体验要求的前提下可以解决白屏问题。

  • html2canvas内置的兼容此问题的方案,如下代码

const scrollTop = document.documentElement.scrollTop||document.body.scrollTop;//得到滚动条高度
const domObj = document.querySelector("#canvas")
html2canvas(domObj, {
  y: scrollTop,//解决有滚动条时,生成海报顶部有空白问题
})

3.3 海报中图片比例不正确(html2canvas)

问题原因

在海报实现过程中,大部分需要对图片进行保留原始比例的剪切、缩放或者直接进行拉伸,这也就用到了object-fit属性。前文提到过html2canvas有一套自己的渲染引擎,对CSS、尤其是新属性支持不太友好,这也就导致再使用html2canvas生成海报时object-fit不生效,与浏览器渲染的海报图片不一致的情况。

解决方案

  • 如果使用html2canvas,可以将<img>标签转为背景图模式,背景图的几个属性html2canvas是支持的。

  • 如果必须使用<img>标签,可以使用SVG生成的js库,如modern-screenshot、dom-to-image等来规避这个问题。

4 总结

现有的技术已经满足服务端、客户端(原生)、前端去实现海报的绘制工作。上文提及到的类库大多底层还是通过Canvas API实现的,在实际使用过程中,可以根据自己的需求及现状选择不同的技术。下面是选型指南:

  • 如服务器性能稳定且排版复杂,推荐使用服务端生成方式。
  • 如需要复杂排版的完美呈现或者有用户交互的场景,推荐使用客户端生成。
  • 如普通的排版或者是较大并发的场景,使用前端生成即可。
  • 前端推荐使用html2canvas或modern-screenshot,两者各有小缺陷,实践中可以替换使用,会规避大部分问题;如果有操作海报元素、高度DIY海报的需求推荐使用Fabric.js。

5 参考文章

  • https://www.npmjs.com/package/html2canvas
  • http://fabricjs.com/
  • https://juejin.cn/post/7339671825646338057
  • https://zhuanlan.zhihu.com/p/701919912
  • https://zhuanlan.zhihu.com/p/338265679

全栈修仙之路
专注分享 TS、Vue3、前端架构和源码解析等技术干货。
 最新文章