背景
有个需求需要实现剪切蒙版的效果,然后发现css里面有个mask-image属性可以比较容易的实现,最后通过html2canvas生成一个封面图,但是发现html2canvas并不支持这个属性,然后看一下html2canvas的提交记录,已经2年没有更新了。所以,自己动手,丰衣足食,只能自己修改了。
源码解析
如果全部代码都看完的话花费的时间肯定是很多,先根据需求来,首先需要的div背景图片的mask-image,只需要了解背景图片是如何实现的就行。
html2canvas的大致流程如下:
具体的绘制方法在renderStackContent方法里面
这是对html层叠上下文的一个实现,刚好一一对应
所以很容易找到背景的绘制方法,没错,就是renderNodeBackgroundAndBorders(),其原理大致如下:
通过clip方法,先裁剪出可绘制区域,这样所有的绘制内容就只会在区域内生效,然后再绘制背景色和背景图片,这就是绘制背景图片的原理,了解到这也就可以开始实现需求的功能了。
准备工作
调试
观察package.json的script,其调试主要是start和watch命令
- watch会监听代码的修改并且编译
- start会启动可以访问项目目录的服务,调试页面主要在examples里面,里面是demo页面
所以只要同时启动这两个脚本就可以一边修改代码一边调试效果了
mask属性支持代码
原来的html2canvas的css对象里面是没有mask相关的属性的,要先实现这个,发现它的属性和background的十分一致,那直接抄就好,省了好多功夫。
scr/css/property-descriptors下直接复制background的文件改名
scr/css/render下直接复制background的文件改名
实现思路
思路1(失败思路)
这是我一开始的思路,因为遇到了没法解决的问题所以无法实现,但是还是记录一下。流程思路如下:
剪切蒙版的原理主要是利用下层图层的透明度进行蒙版,所以只要把图片的透明度替换成蒙版的透明度就好了。
主要利用canvas的getImageData
和putImageData
。
context.getImageData(x, y, width, height)
用于在画布上复制指定矩形的像素数据,
其参数为:
属性 |
含义 |
x |
要复制的矩形区域的左上角的x坐标 |
y |
要复制的矩形区域的左上角的y坐标 |
width |
要复制的矩形区域的宽度 |
height |
要复制的矩形区域的高度 |
其返回值为返回的是一个ImageData对象,该对象包含了三个只读属性:
属性 |
含义 |
ImageData.width |
ImageData的宽度 |
ImageData.height |
ImageData的高度 |
ImageData.data |
类型为Uint8ClampedArray的一维数组,每四个数组元素代表了一个像素点的RGBA信息,每个元素数值介于0~255 |
context.putImageData(imgData,x,y,dirtyX,dirtyY,dirtyWidth,dirtyHeight)
用于图像数据(从指定的 ImageData 对象)放回画布上,其参数为:
属性 |
含义 |
imgData |
要放回画布的 ImageData 对象 |
x |
ImageData 对象左上角的 x 坐标 |
y |
ImageData 对象左上角的 y 坐标 |
dirtyX |
可选。水平值(x),在画布上放置图像的位置 |
dirtyY |
可选。垂直值(y),在画布上放置图像的位置 |
dirtyWidth |
可选。在画布上绘制图像所使用的宽度 |
dirtyHeight |
可选。在画布上绘制图像所使用的高度 |
最后按照流程图来实现功能,最终代码如下:
实现思路是先保存原先已经绘制的画布像素数据,然后绘制图片并保存图片的像素数据,最后是遮罩数据,最后通过透明度来合并三份数据,再重新绘制到画布上。实现过程中,也遇到了一些问题,刚开始忽略了半透明的存在,因为遮罩是自己ps里面画了个圆,本来想着就是图形就是透明度100%,空白地方是0%,其实圆的最外面有很多非100%透明度的像素,所以刚开始实现的时候圆十分不圆滑,显示非常明显的锯齿。最后找了一个合并两个颜色的算法去合并不是介于0-100之间透明度的颜色,解决了这个问题。
到这里已经十分兴奋了,因为确实可以支持mask-image了,但是,最后发现了一个问题没法解决,这个方法也被放弃了。
ok,这个问题就是图片旁边会出现边框,并且在不同的缩放下不一样。html2canvas的绘制前都会先画出要绘制的图形的边框路径,接着使用clip()
方法去裁剪出绘制的区域,clip()
方法剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内(不能访问画布上的其他区域)。所以整个流程下来,也没有发现什么不对的地方,因为每次绘制的宽高和起点坐标都是一样的,所以瞬间懵掉了。一开始猜测是clearRect()
清除不干净,一直搜索为啥clearRect()
清除不干净,也没找到所以然。经过了好一番折腾,在stackoverflow找到了一个答案:
所以即使使用了clip()
,也是有可能绘制到剪切的路径之外的,特别是缩放的时候。由于clearRect()
的时候是按照clip的路径去执行的,所以调用clearRect()
到时候内容一旦绘制到路径之外,肯定没有清除掉,最终产生了内容残留最后看起来像是一个边框。坑爹啊T_T。
思路2
第二种想法是,因为div本身就是一个元素,background-color,background-image,mask-image等属性都在一个canvas合成后,再一次性绘制到画布上就好。
修改renderNodeBackgroundAndBorders依次绘制内容
async renderNodeBackgroundAndBorders(paint: ElementPaint): Promise<void> {
this.applyEffects(paint.getEffects(EffectTarget.BACKGROUND_BORDERS));
const styles = paint.container.styles;
const hasBackground = !isTransparent(styles.backgroundColor) || styles.backgroundImage.length;
const borders = [
{style: styles.borderTopStyle, color: styles.borderTopColor, width: styles.borderTopWidth},
{style: styles.borderRightStyle, color: styles.borderRightColor, width: styles.borderRightWidth},
{style: styles.borderBottomStyle, color: styles.borderBottomColor, width: styles.borderBottomWidth},
{style: styles.borderLeftStyle, color: styles.borderLeftColor, width: styles.borderLeftWidth}
];
const backgroundPaintingArea = calculateBackgroundCurvedPaintingArea(
getBackgroundValueForIndex(styles.backgroundClip, 0),
paint.curves
);
if (hasBackground || styles.boxShadow.length) {
this.ctx.save();
this.path(backgroundPaintingArea);
this.ctx.clip();
const containerBounds = paint.container.bounds;
if (containerBounds.width > 0 && containerBounds.height > 0) {
const tempCanvas = this.canvas.ownerDocument.createElement('canvas');
tempCanvas.width = containerBounds.width;
tempCanvas.height = containerBounds.height;
if (!isTransparent(styles.backgroundColor)) {
this.renderTempBackgroundColor(tempCanvas, styles.backgroundColor);
}
await this.renderTempBackgroundImage(tempCanvas, paint.container);
await this.renderTempMaskImage(
tempCanvas,
paint.container,
containerBounds.width,
containerBounds.height
);
this.ctx.drawImage(
tempCanvas,
containerBounds.left,
containerBounds.top,
containerBounds.width,
containerBounds.height
);
}
this.ctx.restore();
styles.boxShadow
.slice(0)
.reverse()
.forEach((shadow) => {
this.ctx.save();
const borderBoxArea = calculateBorderBoxPath(paint.curves);
const maskOffset = shadow.inset ? 0 : MASK_OFFSET;
const shadowPaintingArea = transformPath(
borderBoxArea,
-maskOffset + (shadow.inset ? 1 : -1) * shadow.spread.number,
(shadow.inset ? 1 : -1) * shadow.spread.number,
shadow.spread.number * (shadow.inset ? -2 : 2),
shadow.spread.number * (shadow.inset ? -2 : 2)
);
if (shadow.inset) {
this.path(borderBoxArea);
this.ctx.clip();
this.mask(shadowPaintingArea);
} else {
this.mask(borderBoxArea);
this.ctx.clip();
this.path(shadowPaintingArea);
}
this.ctx.shadowOffsetX = shadow.offsetX.number + maskOffset;
this.ctx.shadowOffsetY = shadow.offsetY.number;
this.ctx.shadowColor = asString(shadow.color);
this.ctx.shadowBlur = shadow.blur.number;
this.ctx.fillStyle = shadow.inset ? asString(shadow.color) : 'rgba(0,0,0,1)';
this.ctx.fill();
this.ctx.restore();
});
}
let side = 0;
for (const border of borders) {
if (border.style !== BORDER_STYLE.NONE && !isTransparent(border.color) && border.width > 0) {
if (border.style === BORDER_STYLE.DASHED) {
await this.renderDashedDottedBorder(
border.color,
border.width,
side,
paint.curves,
BORDER_STYLE.DASHED
);
} else if (border.style === BORDER_STYLE.DOTTED) {
await this.renderDashedDottedBorder(
border.color,
border.width,
side,
paint.curves,
BORDER_STYLE.DOTTED
);
} else if (border.style === BORDER_STYLE.DOUBLE) {
await this.renderDoubleBorder(border.color, border.width, side, paint.curves);
} else {
await this.renderSolidBorder(border.color, side, paint.curves);
}
}
side++;
}
}
主要解决上面出现的问题,问题的原因是绘制的内容超出了边界,只要一次性把元素绘制上去就能避免的了多次绘制导致的残留,新创建的离屏canvas也能通过重新设置宽度来完全清空内容再重新绘制内容。
更具体的代码可以前往github查看。
测试
到这里功能也完成了,通过demo查看也是实现了功能,上面是html,下面是canvas
自己也懒得写测试了,运行一下test命令保证以前的测试用例都能通过。一运行也是发现了一个问题,就是宽高为0的时候调用drawImage会报错,最后也是加了判断,所以测试还是很有必要的,有机会加一下。
总结
附上代码地址github,同时发布了一份npm,可以体验一下