Skip to main content

Command Palette

Search for a command to run...

domtoimage 是怎么实现截图的

Published
•3 min read•View as Markdown
domtoimage 是怎么实现截图的

这几天做的东西和截图有关,简单来说就是做一个分享卡片组件,然后把他转换成图片用作分享。

不按常理出牌,我安装的是 domtoimage 这个包,因为 html2canvas 生成的结果经常有错位的问题,本文先看一下 domtoimage 的原理。

toPng

先从 toPng 这个 API 入手:

function toPng(node, options) {
    return draw(node, options || {})
        .then(function (canvas) {
            return canvas.toDataURL();
        });
}

toPng 用于将元素保存成图片数据,我知道得把元素绘制在 canvas 上,然后 base64 是 canvas 生成的,但我不知道他具体怎么绘制的,接着找到 draw 函数。

draw

function draw(domNode, options) {
    return toSvg(domNode, options)
        .then(util.makeImage)
        .then(util.delay(100))
        .then(function (image) {
            var canvas = newCanvas(domNode);
            canvas.getContext('2d').drawImage(image, 0, 0);
            return canvas;
        });

    function newCanvas(domNode) {
        var canvas = document.createElement('canvas');
        canvas.width = options.width || util.width(domNode);
        canvas.height = options.height || util.height(domNode);

        if (options.bgcolor) {
            var ctx = canvas.getContext('2d');
            ctx.fillStyle = options.bgcolor;
            ctx.fillRect(0, 0, canvas.width, canvas.height);
        }

        return canvas;
    }
}

他先做了一个内部的 newCanvas 函数,这个函数就是用来创建 canvas 节点的,util.width/height 一眼用于计算元素尺寸,这里可以稍微看一下实现。

width/height

function width(node) {
    var leftBorder = px(node, 'border-left-width');
    var rightBorder = px(node, 'border-right-width');
    return node.scrollWidth + leftBorder + rightBorder;
}

function height(node) {
    var topBorder = px(node, 'border-top-width');
    var bottomBorder = px(node, 'border-bottom-width');
    return node.scrollHeight + topBorder + bottomBorder;
}

他是拿了元素的 border-width 再去加元素总宽高的,scrollWidth/scrollHeight 是包括了滚动条在内的尺寸。

回到刚刚的代码,他又用到了 toSvg 函数(早知道从 toSvg 开始看了)。

toSvg

function toSvg(node, options) {
    options = options || {};
    copyOptions(options);
    return Promise.resolve(node)
        .then(function (node) {
            return cloneNode(node, options.filter, true);
        })
        .then(embedFonts)
        .then(inlineImages)
        .then(applyOptions)
        .then(function (clone) {
            return makeSvgDataUri(clone,
                options.width || util.width(node),
                options.height || util.height(node)
            );
        });

    function applyOptions(clone) {
        if (options.bgcolor) clone.style.backgroundColor = options.bgcolor;

        if (options.width) clone.style.width = options.width + 'px';
        if (options.height) clone.style.height = options.height + 'px';

        if (options.style)
            Object.keys(options.style).forEach(function (property) {
                clone.style[property] = options.style[property];
            });

        return clone;
    }
}

copyOptions 可以稍微跳过,他是用来传递参数的,cloneNode 用于深度复制一个元素和他的 children,就相当于对元素进行预处理吧,比如说:

  1. 对于 canvas,用 DataUrl 转换成 img 来显示

  2. 遍历复制每一个子元素,根据 filter 参数判断要不要保留

  3. 复制元素的样式,包括伪元素的

  4. 对于表单元素,拿到输入的内容

  5. 对于 svg,会重新设置命名空间

预处理后,按 embedFonts、inlineImages、applyOptions 的顺序依次调用,embedFonts 的作用是收集页面内所有的字体相关样式,然后做一个内部样式去一次性定义他们。

inlineImages 的作用是找到所有的 img 元素,将他们的 src 转换为 DataUrl 的形式,设置为内联样式。

applyOptions 没什么好说的,把 API 传入的参数和实际的元素属性合并,比如外部设置了 width,那么就优先使用,覆盖元素的 width。

makeSvgDataUri

经过多重处理,接下来就是把节点转换成 svg 数据了,这里调用的是 makeSvgDataUri:

function makeSvgDataUri(node, width, height) {
    return Promise.resolve(node)
        .then(function (node) {
            node.setAttribute('xmlns', '<http://www.w3.org/1999/xhtml>');
            return new XMLSerializer().serializeToString(node);
        })
        .then(util.escapeXhtml)
        .then(function (xhtml) {
            return '<foreignObject x="0" y="0" width="100%" height="100%">' + xhtml + '</foreignObject>';
        })
        .then(function (foreignObject) {
            return '<svg xmlns="<http://www.w3.org/2000/svg>" width="' + width + '" height="' + height + '">' +
                foreignObject + '</svg>';
        })
        .then(function (svg) {
            return 'data:image/svg+xml;charset=utf-8,' + svg;
        });
}

先给节点设置上 SVG 的命名空间,这一段是用来指定浏览器的解析方式的,避免不同浏览器上的解析错误。

XMLSerializer().serializeToString 用于将元素序列化成 XML 的形式,然后使用 escapeXhtml 转义(针对 # 和 \\n 字符) ,把会引发错误解析的部分去掉。

接着定义一个 foreignObject 去包裹这段 xml,foreignObject 的用处是在 SVG 文档中渲染非 SVG 的内容,这些内容会被浏览器按照自己的语法、样式规则去处理。

下一步就是用 SVG 元素包裹 foreignObject。

所以将一个元素转换成 SVG,实际上是在 SVG 中开辟一个区域,“在符合 SVG 规范的前提下,渲染非 SVG 的文档”,你看起来里面的内容组成都是 HTML,但实际上它符合 SVG 规范,他就是一个 SVG。

对于 SVG 来说,转换成 dataUrl 就简单了,直接在前面加上 MimeType 等信息就好了。

回到 draw

function draw(domNode, options) {
    return toSvg(domNode, options)
        .then(util.makeImage)
        .then(util.delay(100))
        .then(function (image) {
            var canvas = newCanvas(domNode);
            canvas.getContext('2d').drawImage(image, 0, 0);
            return canvas;
        });

    function newCanvas(domNode) {
        var canvas = document.createElement('canvas');
        canvas.width = options.width || util.width(domNode);
        canvas.height = options.height || util.height(domNode);

        if (options.bgcolor) {
            var ctx = canvas.getContext('2d');
            ctx.fillStyle = options.bgcolor;
            ctx.fillRect(0, 0, canvas.width, canvas.height);
        }

        return canvas;
    }
}

回到 draw 函数,现在知道了 makeSvgDataUri 是如何将元素转换成 SVG dataUrl 的,接下来会通过 util.makeImage 将 dataUrl 转换成 Image 对象。

之后的流程就很清楚了,下一步是创建 Canvas,然后用 Canvas API 将 Image 对象绘制到其中,之后再通过 toDataURL 拿到转换成了指定格式(这里是 PNG)图像的二进制数据(以 base64 呈现),最后交由 API 返回。

总结

所以整体的流程是这样的:

  1. 一个元素,通过 toPng 等 API 进行截图

  2. 对元素和他的子元素进行遍历,进行预处理

    1. Canvas 转 img

    2. 远程资源序列化储存

    3. 样式收集,字体收集

    4. …

  3. 将元素转换成符合 SVG 标准的形式

  4. 将 SVG 以 DataUrl 的形式导入到 Image

  5. Cavans 绘制出 Image

  6. 通过 toDataURL 拿到完整图像

More from this blog

12 月装机行动记录

(Banner 图文无关) 这几天买了新的装备回来升级配置,除了显卡和散热器,其他都更新了,这两个不更新的原因是太贵了。 由于我不太懂选配置,所以还是让朋友给推荐,我说我的预算在 5000 左右,他就给我转了一个整机,配置大概是这样的: CPU:AMD 9700X 主板:微星 B650M GAMING PLUS WIFI 或者 微星 B650M GAMING WIFI 显卡:木有 内存:英睿达/宇瞻 DDR5 6000 32G 硬盘:1T NVMe PCIe4 SSD 读速 3500M...

Dec 7, 20241 min read
12 月装机行动记录

Homekit + cozylife 插座连接 HA

近期在淘宝上买了个 Homekit + cozylife 的插座,就这种: 一开始只通过 iOS 访问,就是只连接 Homekit,长按开关重置插座,iOS 一扫码就连上了,后来我嫌在外面访问不了,又不想掏钱买苹果的 HomePod,于是就装上了 Home Assistant,打算让设备们都连上 HA,这样就不用交苹果税了。 连接方式还是通过 Homekit,一般来说支持 Homekit 设备都能这样连接,先连上 iOS,然后在 Home App 中移除设备,这时候就能在 HA 中找到设备了:...

Nov 30, 20241 min read
Homekit + cozylife 插座连接 HA

找到了一台祖传的 Ccd 相机

开个玩笑,这台相机其实是我们家在 05 年的时候买的,发票都还在呢,当时花了 4000 块钱!搁现在我都受不了,更别说当年了,看到价格我都震惊了。 相机的型号是索尼的 Cybershot DSC-N1,属于小红书时尚单品 CCD 相机,由于一直放在包装盒里,现在还有 99 新呢。 机子还是正常的,能开机,能拍照,其中一个问题是日期,这款没有 WiFi 功能,所以时间只能保存在本地,不知道是不是 BUG,每次开机都让我重新设置,默认就定在 2005 年 1 月 1 日。 第二个问题是电池,电池应该...

Nov 28, 20241 min read
找到了一台祖传的 Ccd 相机

记录和 ffmpeg 与 LLM 搏斗的两天

要做的 最近在写一个制作视频的功能,就是把 N 个视频合并,然后把对应的 N 张图片,在视频开始的前 5 秒叠加显示出来。 第一口 - diffusion studio 本来我用的是 diffusion studio,这是一个 JS 库,但这玩意性能太差了,因为他要把视频每一帧都读到 canvas 里,数据一多页面就卡住了(为啥要折腾 DOM 呢?) 而且他的 API 十分不好用,作为浏览器脚本你无法读本地数据也就算了,你起码给一个接受纯数据的参数吧,比如 HTML 类型接受源代码,Image ...

Nov 16, 20242 min read
记录和 ffmpeg 与 LLM 搏斗的两天
V

void mian

39 posts