如果要生成海报图片,要分两步才能完成:

  1. 绘制 canvas
  2. canvas 转换成图片

第一步:绘制 canvas

如果要生成海报图片,第一步是根据设计稿,将要展示的内容绘制到 canvas 上。绘制 canvas 有两种不同的方式:直接绘制和使用html2canvas 进行转换。

直接绘制

直接绘制就是使用 canvas 的方法将要展示的元素一个个绘制出来。如果要展示的元素相对来说比较简单也不经常变动,推荐使用这种方式,因为和使用html2canvas 相比,直接绘制不用依赖外部,项目可以小而精。下面是我在使用过程中遇到的问题:

  1. 尺寸转换问题
    说到尺寸,首先我们要注意 canvas 元素上和 CSS 样式中的 width / height 属性的区别:canvas 元素上的属性是画布的宽高,而 CSS 样式中的宽高是 canvas 元素在页面上展示的大小。为什么区分这两个尺寸呢, 因为 canvas 是位图模式的,如果不做高清屏适配的话浏览器就会以多个像素点的宽度来渲染一个像素,图片就会很模糊,因此需要将 canvas 画布的宽高乘以 window.devicePixelRatio。如果在二倍屏的手机上展示,两者的宽高应该是这样设置的:

    1
    <canvas id="myCanvas" width="200px" height="200px" style="width: 100px; height: 100px"></canvas>
  2. 绘制层级问题
    使用 canvas 绘制时,还要注意层级的问题。我之前用 canvas 绘制的时候,因为没有对 canvas 的功能没有做深入的了解,走了不少的弯路。比如要将文字绘制到图片背景之上,因为图片加载是异步的,为了保证图片绘制时不会遮挡文字,将绘制文字的逻辑写到了绘制图片的回调里,如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const image = new Image()
    image.onload = () => {
    context.drawImage(image, 0, 0, 300, 200)
    const text = 'Hello World'
    context.fillStyle = '#fff'
    context.font = '20px 微软雅黑'
    context.fillText(text, 100, 100)
    }
    image.src = '...'

    如果有更复杂的层叠情况,简直要陷入回调地狱,也不利于封装。这时候,我们需要用到 canvas 的 globalCompositeOperation 属性。 globalCompositeOperation 属性用于设置或返回如何将一个源(新的)图像绘制到目标(已有)的图像上。

  • 源图像 = 打算放置到画布上的绘图
  • 目标图像 = 已经放置在画布上的绘图

globalCompositeOperation 的默认值是 source-over, 即在目标图像上显示源图像,也就是说源图像绘制时如果和目标图像有重叠的话,会直接进行覆盖。我们可以将 globalCompositeOperation 设置为 destination-over,表示在目标图像上方显示源图像:

1
2
3
4
5
6
7
8
9
10
11
const image = new Image()
image.onload = () => {
context.drawImage(image, 0, 0, 300, 200)
}
image.src = '...'

context.globalCompositeOperation = 'destination-over'
const text = 'Hello World'
context.fillStyle = '#fff'
context.font = '20px 微软雅黑'
context.fillText(text, 100, 100)

按照上面的写法,即使图片是后绘制的,也不会把文字覆盖掉。我们可以按照这个思路,梳理海报中内容的层叠关系,分层进行绘制,这样就不用担心因为绘制的先后而会相互覆盖了。

  1. 文本换行和缩进问题

如果要进行文本换行或者缩进,不像我们平时写样式用几个 css 属性就能实现,需要手动写逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
 const drawIndentText = (context, canvasWidth, str, initX, initY, lineHeight, indentWidth, maxLineCount) => {
let lineWidth = 0
let lineCount = 0
for (let i = 0; i < str.length; i++) {
lineWidth += context.measureText(str[i]).width
if (indentWidth > 0 && lineWidth > canvasWidth - 2 * initX - indentWidth) {
context.fillText(str.substr(0, i), initX + indentWidth, initY)
initY += lineHeight
lineWidth = 0
indentWidth = 0
str = str.substr(i)
i = -1
lineCount += 1
}
if (lineWidth > canvasWidth - 2 * initX) {
context.fillText(str.substr(0, i), initX, initY)
initY += lineHeight
str = str.substr(i)
i = -1
lineWidth = 0
lineCount += 1
}
if (i === str.length - 1) {
if (lineCount) {
// 最多展示 maxLineCount 行
if (lineCount < maxLineCount) {
context.fillText(str.substr(0, i + 1), initX, initY)
}
} else {
// 只有一行文案也要缩进
context.fillText(str.substr(0, i + 1), initX + indentWidth, initY)
}
}
}
return initY
}
  1. iOS 和 Android 兼容问题

即使使用 canvas 绘制,也避免不了兼容性问题,比如绘制文字时 iOS 和安卓是有区别的,如果想要上下居中,需要注意对此做区分。因为没有足够的机型做统计,绘制文字时我一般把安卓的 y 值设置的比 iOS 多几个像素。

  1. 图片跨域问题

在绘制图片的时候,如果出现 Tainted canvases may not be exported 的错误,是因为图片跨域引起的,需要给图片添加 crossOrigin 属性,如下:

1
image.setAttribute('crossOrigin', 'anonymous')

当然,如果图片不支持跨域,比如微信头像返回头是不允许跨域的,即使设置了 crossOrigin 也没有用,这时需要我们就需要使用 nginx 代理。

1
2
3
4
5
6
7
location /wechat_image/ {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With' always;
proxy_pass http://thirdwx.qlogo.cn/;
}
  1. 图片加载完成问题

因为图片加载是异步的,所以需要注意图片全部加载完成之后,canvas 才算绘制完成,我们可以使用 Promise.all 等方式,这里不再多说。

使用 html2canvas

和直接绘制相比,使用 html2canvas 是更为便捷,我们可以直接将 dom 转成 canvas, 这种方式适用于逻辑复杂、频繁变动的项目。但是引用外部的包也是把双刃剑,因为如果包本身出bug了,我们就会很被动。

  1. 文字或者 border 不展示的问题
    遇到文字或者 border 不展示,可以使用 html2canvas 的 1.0.0-alpha.12 版本。(使用低版本不是一个好办法哦~)

  2. background 覆盖的问题
    假如我们使用了两个 span 包裹文字,第二个 span 的文字设置了背景颜色,那么就会出现第一个 span 就会被第二个 span 的背景颜色覆盖掉,如下:

html
图1:html展示的内容
html2canvas
图2:html2canvas 转换的结果

从图上可以看出,折行文字的背景色覆盖了前后的文字,因为一旦文字折行。遇到这种情况,就只能在转 canvas 的时候将背景去掉:

1
2
3
4
5
6
7
8
9
10
const nodesWithBg = doc.querySelectorAll('[style*=background]')
if (nodesWithBg) {
nodesWithBg.forEach(element => {
// 表格的背景色要保留
const tagsExclude = ['table', 'thead' ,'tbody', 'tfoot', 'tr', 'td', 'th']
if (!(element.tagName && tagsExclude.includes(element.tagName.toLowerCase()))) {
element.style.backgroundColor = 'transparent'
}
})
}

以上两种方式,如果让我选的话,我会选择第一种,因为使用 html2canvas,一旦出现问题,我们就只能在产品功能上做妥协,这可能会背离项目开始的初衷。

第二步:canvas 转换成图片

将 canvas 转成图片很简单,核心代码如下:

1
canvas.toDataURL('image/png')

这一步我使用了 canvas2image 的 npm 包,这个包的逻辑比较简单,主要是支持将 canvas 转换成不同的格式和尺寸。我们在这一步注意的问题是 canvas 默认是抗锯齿的,如果有些像素不想被平滑消掉,需要在将 canvas 转成图片之前,首先需要关闭 canvas 的抗锯齿:

1
2
3
4
context.mozImageSmoothingEnabled = false
context.webkitImageSmoothingEnabled = false
context.msImageSmoothingEnabled = false
context.imageSmoothingEnabled = false