图片生成有感

最近在业务中遇到使用生成图片的需求,图片只需要展示数据,没有计算密集型工作。后端生成的图片字体太单一,工作就交给了前端。从0开始作图做像素级的操作自然是不现实的,有幸的是,HTML本身就是一个很不错的做UI的语言,有CSS的支持。再借助HTML to canvas或是SVG的库,可以完成想要的需求。

实际上,后端大多数也是通过起chrome内核,绘制DOM节点生成图片的

需求

  • 按照指定格式生成图片
  • 保证格式正确清晰度高
  • 生成过程用户无感知
  • 对图片格式没有明确要求

解决方案

HTML to image有两种方案比较流行,一个是html2canvas,一个是dom-to-image。它们的设计初衷其实都是将已有DOM结构转成图片类型。对比来看

  • 流行度上,html2canvas流行度更高,资料更好找,但更新缓慢
  • 格式支持上,dom-to-image可以将图转成SVG等更多格式,html2canvas只能输出canvas,需要用户自行处理
  • 清晰度上,dom-to-image可以导出SVG,html2canvas则需要hack的方式(设置更大的canvas绘制再等比缩放)
  • 实现原理上,都是通过遍历DOM树,读取格式化数据,dom-to-image通过浏览器解析CSS语法,因此支持度更高;html2canvas则自己实现了CSS解析

渲染图片的HTML模板在通常情况下,不应该展示给用户。即生成过程短暂停留的DOM需要用户不可见。不可见的方式大致有下面几种:

  • display: none,这种情况,两个方案度都输出空白图片
  • visibility: hidden,在输出图片时,DOM结构会短暂闪现,两种方案都输出空白图片
  • 将DOM移出视口,html2canvas可以正确输出图片,dom-to-image不行

本场景下生成的图片需要上传,并最后展示给C端,没有对SVG的需求。测试来看,两者的输出结果清晰度类似,且html2canvas输出格式还原度更高。综合考虑,选择html2canvas。

在其他场景下,如支持SVG、需要高清截图、需要导出更多图片时,可以考虑使用dom-to-image。两者的API实际上非常类似。

容器组件

考虑到未来仍可能存在的前端图片渲染需求,将相关逻辑内聚成一个组件,同时开发接口给外部使用。

组件需要输入:

  • hide,因为渲染过程是componentDidMount阶段完成的,在每次渲染完成后要在父组件手动卸载该组件,这部分需要在hide中实现
  • success,可选的成功回调,入参是生成的canvas,hide作为可选第二个入参,可以异步卸载组件
  • {children},无状态的函数组件,只负责图片的HTML模板
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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/*
* @author: shenlvmeng
* @desc: 渲染图片的容器组件,加载时根据内部DOM生成图片,输出data64编码到回调
* @props: hide {Function} required 图片生成完成后需要在父组件执行的卸载该组件操作
* @props: success {Function} 成功回调
**/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import html2canvas from 'html2canvas';

class GeneratedImage extends Component {
static defaultProps = {
hide: () => {},
success: (canvas) => {document.body.appendChild(canvas);}
};

static propTypes = {
hide: PropTypes.func,
success: PropTypes.func
};

componentDidMount() {
html2canvas(document.getElementById('html2canvas'))
.then((canvas) => {
// 可以将hide操作作为success的回调使用
if (this.props.success.length > 1) {
this.props.success(canvas, this.props.hide);
} else {
this.props.success(canvas);
this.props.hide();
}
})
.catch((e) => {
console.error(e);
this.props.hide();
});
}

render() {
return (
<div
id="html2canvas"
style={{
position: 'fixed',
left: '-9999px'
}}
>
{this.props.children}
</div>
);
}
}

export default GeneratedImage;

使用时,像如下这样,在对应的时机展示组件即可:

1
2
3
4
5
6
7
8
9
{ this.state.isGenerating ?
<GeneratedImage
hide={() => {this.setState({ isGenerating: false })}}
success={(canvas) => { console.log(canvas.toDataURL()); }}
>
<Image />
</GeneratedImage>
: null
}

已知缺陷

  • 对部分CSS属性支持度有限,如box-shadow-webkit-line-clampbackground-position
  • 使用时需要额外的卸载操作

后端

生成图片的业务需求大多数是用内容填充的,因此使用浏览器渲染页面再截图是比较直观的生成方式(qrcode这种简单的图片需求另说)。在使用python的场景下,可以用selenium生成,代码非常简单。

首先,pip install selenium,如果是python3,就pip3 install selenium

然后,安装chromedriver。使用headless模式打开chrome,并根据图片位置和大小截图即可。

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
37
38
from selenium import webdriver
from PIL import Image
from io import BytesIO
from os import path

def screenshot(path):
# Headless chrome

DRIVER = 'chromedriver' # add this to your $PATH
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--disable-gpu')

# get screenshot

browser = webdriver.Chrome(chrome_options=chrome_options, executable_path=DRIVER)
browser.get(path)
ele = browser.find_element_by_id('demo')
location = ele.location
size = ele.size
image = browser.get_screenshot_as_png()
browser.quit()

# crop image

im = Image.open(BytesIO(image))

left = location['x']
top = location['y']
right = location['x'] + size['width'] * 2
bottom = location['y'] + size['height'] * 2

im = im.crop((left, top, right, bottom))
im.save('screenshot.png')

if (__name__ == '__main__'):
curr_path = path.dirname(path.realpath(__file__))
screenshot('file://' + curr_path + '/demo.html')

这样就可以获取到示例页面的截图。