加载中...

修改html2canvas使其支持mask-image

博客 2024.02.28 10:45 568

背景

有个需求需要实现剪切蒙版的效果,然后发现css里面有个mask-image属性可以比较容易的实现,最后通过html2canvas生成一个封面图,但是发现html2canvas并不支持这个属性,然后看一下html2canvas的提交记录,已经2年没有更新了。所以,自己动手,丰衣足食,只能自己修改了。

源码解析

如果全部代码都看完的话花费的时间肯定是很多,先根据需求来,首先需要的div背景图片的mask-image,只需要了解背景图片是如何实现的就行。
html2canvas的大致流程如下:

code1.png

具体的绘制方法在renderStackContent方法里面

renderStatckContent.png
这是对html层叠上下文的一个实现,刚好一一对应

29e46f181c4e848e0f4a46767c6f0aef.jpg

所以很容易找到背景的绘制方法,没错,就是renderNodeBackgroundAndBorders(),其原理大致如下:

backgroundrender.png

通过clip方法,先裁剪出可绘制区域,这样所有的绘制内容就只会在区域内生效,然后再绘制背景色和背景图片,这就是绘制背景图片的原理,了解到这也就可以开始实现需求的功能了。

准备工作

调试

观察package.json的script,其调试主要是start和watch命令
script.png

  • watch会监听代码的修改并且编译
  • start会启动可以访问项目目录的服务,调试页面主要在examples里面,里面是demo页面

所以只要同时启动这两个脚本就可以一边修改代码一边调试效果了

mask属性支持代码

原来的html2canvas的css对象里面是没有mask相关的属性的,要先实现这个,发现它的属性和background的十分一致,那直接抄就好,省了好多功夫。

scr/css/property-descriptors下直接复制background的文件改名

企业微信截图_20240218180817.png

scr/css/render下直接复制background的文件改名

企业微信截图_20240218181007.png

实现思路

思路1(失败思路)

这是我一开始的思路,因为遇到了没法解决的问题所以无法实现,但是还是记录一下。流程思路如下:

merge.png

剪切蒙版的原理主要是利用下层图层的透明度进行蒙版,所以只要把图片的透明度替换成蒙版的透明度就好了。
主要利用canvas的getImageDataputImageData

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 可选。在画布上绘制图像所使用的高度

最后按照流程图来实现功能,最终代码如下:

code2.png
实现思路是先保存原先已经绘制的画布像素数据,然后绘制图片并保存图片的像素数据,最后是遮罩数据,最后通过透明度来合并三份数据,再重新绘制到画布上。实现过程中,也遇到了一些问题,刚开始忽略了半透明的存在,因为遮罩是自己ps里面画了个圆,本来想着就是图形就是透明度100%,空白地方是0%,其实圆的最外面有很多非100%透明度的像素,所以刚开始实现的时候圆十分不圆滑,显示非常明显的锯齿。最后找了一个合并两个颜色的算法去合并不是介于0-100之间透明度的颜色,解决了这个问题。

到这里已经十分兴奋了,因为确实可以支持mask-image了,但是,最后发现了一个问题没法解决,这个方法也被放弃了。

ok,这个问题就是图片旁边会出现边框,并且在不同的缩放下不一样。html2canvas的绘制前都会先画出要绘制的图形的边框路径,接着使用clip()方法去裁剪出绘制的区域,clip()方法剪切了某个区域,则所有之后的绘图都会被限制在被剪切的区域内(不能访问画布上的其他区域)。所以整个流程下来,也没有发现什么不对的地方,因为每次绘制的宽高和起点坐标都是一样的,所以瞬间懵掉了。一开始猜测是clearRect()清除不干净,一直搜索为啥clearRect()清除不干净,也没找到所以然。经过了好一番折腾,在stackoverflow找到了一个答案:
answer.png
所以即使使用了clip(),也是有可能绘制到剪切的路径之外的,特别是缩放的时候。由于clearRect()的时候是按照clip的路径去执行的,所以调用clearRect()到时候内容一旦绘制到路径之外,肯定没有清除掉,最终产生了内容残留最后看起来像是一个边框。坑爹啊T_T。

思路2

第二种想法是,因为div本身就是一个元素,background-color,background-image,mask-image等属性都在一个canvas合成后,再一次性绘制到画布上就好。

newrender.png

修改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

Snipaste_20240228_182959.png

自己也懒得写测试了,运行一下test命令保证以前的测试用例都能通过。一运行也是发现了一个问题,就是宽高为0的时候调用drawImage会报错,最后也是加了判断,所以测试还是很有必要的,有机会加一下。

总结

附上代码地址github,同时发布了一份npm,可以体验一下