修改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,可以体验一下

相关推荐
糕冷小美n5 小时前
elementuivue2表格不覆盖整个表格添加固定属性
前端·javascript·elementui
小哥不太逍遥5 小时前
Technical Report 2024
java·服务器·前端
沐墨染5 小时前
黑词分析与可疑对话挖掘组件的设计与实现
前端·elementui·数据挖掘·数据分析·vue·visual studio code
anOnion5 小时前
构建无障碍组件之Disclosure Pattern
前端·html·交互设计
threerocks5 小时前
前端将死,Agent 永生
前端·人工智能·ai编程
问道飞鱼6 小时前
【前端知识】Vite用法从入门到实战
前端·vite·项目构建
爱上妖精的尾巴6 小时前
8-10 WPS JSA 正则表达式:贪婪匹配
服务器·前端·javascript·正则表达式·wps·jsa
Aliex_git8 小时前
浏览器 API 兼容性解决方案
前端·笔记·学习
独泪了无痕8 小时前
useStorage:本地数据持久化利器
前端·vue.js
程序员林北北8 小时前
【前端进阶之旅】JavaScript 一些常用的简写技巧
开发语言·前端·javascript