修改html2canvas使其支持mask-image

背景

有个需求需要实现剪切蒙版的效果,然后发现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的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 可选。在画布上绘制图像所使用的高度

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

实现思路是先保存原先已经绘制的画布像素数据,然后绘制图片并保存图片的像素数据,最后是遮罩数据,最后通过透明度来合并三份数据,再重新绘制到画布上。实现过程中,也遇到了一些问题,刚开始忽略了半透明的存在,因为遮罩是自己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依次绘制内容

js 复制代码
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,可以体验一下

相关推荐
爱因斯坦乐8 小时前
Vue项目整合
前端·javascript·vue.js
无风听海8 小时前
IndexedDB 深度指南 浏览器中的事务型对象数据库
前端·数据库
ct9789 小时前
组件间的通信
前端·javascript·vue.js
左手吻左脸。9 小时前
Vue 全栈面试题大全(2026 最新版最详细)
前端·javascript·vue.js
Aphasia3119 小时前
手写KeepAlive组件
前端·react.js·面试
两个西柚呀9 小时前
js中的同步和异步,三种处理异步任务的方式
前端·javascript
pe7er10 小时前
软件设计不要“既要又要”
前端·后端·架构
kyriewen10 小时前
从Webpack到Vite:我们迁移了一个10万行代码的项目,总结了这7个坑
前端·webpack·vite
IT_陈寒10 小时前
Java Stream并行流的坑:我花了3小时才找到的线程安全问题
前端·人工智能·后端
小新11010 小时前
最简单但完整的 Vue 响应式示例(一个简单的计数器按钮)
前端·javascript·vue.js