DOM & BOM – 用 Canvas 修图
前言
以前有写过一篇关于 canvas 处理图片的文章. 非常乱, 这篇做一个整理.
参考
Stack Overflow – HTML5 Canvas Rotate Image
Stack Overflow – How to flip images horizontally with HTML5
Import to and Export from Canvas (导入, 导出图片)
fetch an image
const response = await fetch(`https://192.168.1.152:4201/src/images/tifa2.jpg`); // ajax an image const blob = await response.blob(); // get blob from response const image = await createImageBitmap(blob); // convert blob to Image console.log(`${image.width} x ${image.height}`); // get image info 1600 x 900
流程 : request image > response to blob > blob to Image
import image to canvas
const canvas = document.createElement('canvas'); [canvas.width, canvas.height] = [image.width, image.height]; // set canvas dimension const context = canvas.getContext('2d')!; context.drawImage(image, 0, 0); // drawImage, image can be jpeg, png, webp 等等等 document.body.appendChild(canvas);
canvas 就是一个画布, 我们想把整张图画进去, 所以 canvas 的大小一定要大过图片. 这里我们 set 成一样大小就好.
context.drawImage 就是把 image 画进去 canvas. 这个 image 对象就是上面我们从 blob convert 来的, jpeg, png, webp 等都接受.
export from canvas
canvas.toBlob(blob => { const url = window.URL.createObjectURL(blob!); const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'tifa.jpg'; anchor.click(); }, 'image/jpeg');
canvas.toBlob 可以把 canvas 所以内容 convert 成图片. 支持多种格式, 比如 jpeg, png, webp 等.
它还有一个参数可以设置 quality 0 到 1.
如果没有填, 那游览器会用自己的默认值. 我的经验是, 设置 1 通常会比原图还大的多, 0.95 会差不多, 但是我建议还是用游览器默认会比较好.
转换成 blob 以后, 我们就可以做任何后续的处理了, 比如 download or upload.
另外,还有一种 force open save as dialog 的下载方式,有兴趣可以参考: showSaveFilePicker
cross-origin 和 Security and tainted canvases
不清楚什么是跨域的,先看这篇。
参考:
MDN – Allowing cross-origin use of images and canvas
Stack Overflow – Tainted canvases may not be exported
当 canvas 包含跨域图片时,它是不允许被 JS 读取的。
你尝试调用 getImageData()、toBlob()、toDataURL() 这类方法,游览器就会报错 "Failed to execute 'toBlob' on 'HTMLCanvasElement': Tainted canvases may not be exported".
跨域图片哪来的呢?
我们用 fetch (ajax) 获取图片,如果对方不允许跨域,那你压根拿不到这张图,ajax response 时就报错了。
但我们不一定要用 fetch,我们可以用 new Image()。这样至少可以拿到图 (即使是跨域并且对方没有允许)。
但是呢,这张图游览器只允许用户 "看见" (你可以用 canvas 把它画出来),但是游览器不允许 JS 代码读取这张图的信息。
所以 getImageData 会报错。
游览器的安全考量是这样的
小明访问 a.com,a.com 去 b.com 拿图片。但是 b.com 不允许跨域 (意思是,这张图不允许其它网站以 JS 的方式读取信息)
a.com 可以用 JS 把这张图展现给小明。但是 a.com 不可以用 JS 读取这张图信息做别的事。这就是游览器对跨域的保护。
总结,要 getImageData 或 toBlob 我们要确保拿到的图片是允许跨域的。
若使用 new Image 获取图片,我们需要加上 crossOrigin,并且对方服务器需要配合返回 Access-Control-Allow-Origin
const image = new Image(); image.crossOrigin = 'anonymous'; // 关键 image.addEventListener('load', () => { ctx.drawImage(image, 0, 0); }); image.src = 'https://cdn-icons-png.flaticon.com/512/5360/5360938.png';
如果对方服务器无法配合,那我们就无法单靠前端去完成这件事了,我们需要靠后端去下载图片,保存在同域,才可以避开跨域的问题。
为什么靠后端就可以?因为跨域保护只是游览器的机制,用后端发请求本来就可以轻松下载图片做任何事情。(游览器只是不相信 JS 而已)
提醒:图片通常有缓存的概念,假如第一次访问图片时,我们没加上 crossOrigin,那第二次再想加 crossOrigin 就晚了,因为第二次会直接使用缓存的 response,而这个 response 是没有
Access-Control-Allow-Origin 的。所以记得,假如一张图片有可能需要 crossOrigin,请统一每一次请求它时都配上 crossOrigin。
Convert Image Format
其实上一段已经讲了. 就是在 canvas.blob 的使用选类型就可以了.
canvas.toBlob(blob => {}, 'image/webp'); canvas.toBlob(blob => {}, 'image/jpeg'); canvas.toBlob(blob => {}, 'image/png');
drawImage 参数详解
在实现其它修图前, 我们先认识一下 canvas context 的 drawImage 功能.
它一共有 3 个重载方法, 我们直接看最复杂的就可以了. 其余 2 个底层都是调用这一个.
它虽然有 9 个参数, 但我们可以把它分成 3 个来看.
第一个是 image 就不需要介绍了.
第二个是 sx, sy, sw, sh
x y 是 coordinate, w h 是 width 和 height
试想我们有一张图. 我们要把它画进 canvas. 这时我们可以选择是否把整张图都画进去, 或者只画某一部分. (AKA Crop 截图), 比如
我只想把中间的小猫画进去, 那么我的 sx, sy, sw, sh 的参数就是上面这样
第三个参数是 dx, dy, dw, dh
和参数 2 一样, x y 是 coordinate, w h 是 width 和 height
只是这一次的对象不是 image 而是 canvas
canvas 是画布, 它不一定非得和图片一样大, 试想我们的 canvas 很大, 我想把刚才的小猫画到靠近中间区域.
外面红色框是整个 canvas 范围. 中间是被画进来的小猫.
negative width height
negative width, height 可以帮助我们设置绘画的坐标.
它类似于 CSS 的 position + translate 居中
position top: 50%, left 50% + translate(-50%-50%)
用 positive 的方式画出来的效果是这样
图不在中心点.
加上 negative 之后
图片居中了.
Thumbnail Image (缩率图)
原图 1600 x 900
缩率图 160 x 90
首先是 canvas dimension 要改
[canvas.width, canvas.height] = [image.width * 0.1, image.height * 0.1];
最终输出的图要求是 160 x 90, 所以 canvas 的 dimension 必须是这个.
接着是 drawImage 要改
context.drawImage(image, 0, 0, canvas.width, canvas.height);
这是第二重载方法, 参数是 dx, dy, dw, dh, 对象是 canvas.
x, y = 0, 0 表示从 canvas 左上角开始画
width, height 是 canvas width, height 也就是 160 x 90, 这表示把原图 1600 x 900 画进 160 x 90 的 canvas 里
这样就实现把图片缩小的效果.
Rotate Image
context.translate
除了 drawImage 时, 我们可以调整下笔的 coordinate, 还有一个方式是 translate
context.translate(50, 50);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
效果
在绘画前, 通过 translate 调整下笔的 coordinate. 调整后 drawImage 的 coordinate 虽然是 0, 0 但画下去的地方已经不是 canvas 的 0, 0 了
context.rotate
context.translate(50, 50); context.rotate(degreeToRadian(25)); // 相等于 45 * Math.PI / 180 context.drawImage(image, 0, 0, canvas.width, canvas.height);
在绘画前调整绘画的方向.
注: rotate 的参数是 radian 而不是 degree 哦, 需要自己转换一下.
效果
rotate 90°
首先是 canvas 最终的 dimension
[canvas.width, canvas.height] = [image.height * 0.25, image.width * 0.25];
* 0.25 是因为我的图片调大, 我随便做了缩略.
接着把笔头移动到右侧
context.translate(canvas.width, 0);
如果现在画, 效果是这样的.
context.drawImage(image, 0, 0, canvas.width, canvas.height);
红色是 canvas, 绿色是图片, 这明显不是我们要的效果.
再加上 rotate
context.rotate(degreeToRadian(90));
方向对了, 但是画的 width 和 height 不对.
因为方向换了, 之前是 horizontal 所以是 canvas.width, canvas.height
现在是 vertical, 所以是反过来 canvas.height, canvas.width
context.drawImage(image, 0, 0, canvas.height, canvas.width);
效果
rotate 180°
[canvas.width, canvas.height] = [image.width * 0.2, image.height * 0.2]; context.translate(canvas.width, canvas.height); // 移动到右下角 context.rotate(degreeToRadian(180)); context.drawImage(image, 0, 0, canvas.width, canvas.height);
和上面原理是一样的.
rotate -90° or 270°
[canvas.width, canvas.height] = [image.height * 0.2, image.width * 0.2]; context.translate(0, canvas.height); // 移动到右下角 context.rotate(degreeToRadian(-90)); context.drawImage(image, 0, 0, canvas.height, canvas.width);
效果
Flip Image
CSS 实现 flip 效果是透过 scaleX, scaleY
canvas 也是透过 scale
flip horizontal
[canvas.width, canvas.height] = [image.width * 0.2, image.height * 0.2]; context.scale(-1, 1); context.drawImage(image, 0, 0, canvas.width, canvas.height);
效果
红框是 canvas, 绿框是图. 看绿色箭头, horizontal 的方向改变了. 它现在是往右边绘画.
flip 和 rotate 类似, 必须搭配 translate 才能正确画出结果.
先把 translate 调到右上角
[canvas.width, canvas.height] = [image.width * 0.2, image.height * 0.2]; context.translate(canvas.width, 0); context.scale(-1, 1); context.drawImage(image, 0, 0, canvas.width, canvas.height);
效果
flip vertical
和 horizontal 同一个原理
[canvas.width, canvas.height] = [image.width * 0.2, image.height * 0.2]; context.translate(canvas.width, canvas.height); context.scale(-1, -1); context.drawImage(image, 0, 0, canvas.width, canvas.height);
效果
把下笔点移动到右下角, 然后往左上角作画. 效果就出来了.
Water Mark
效果
水印的原理就是 draw 2 张图. 第二张会覆盖第一张.
如果想要透明, 就在下笔前设置
context.globalAlpha = 0.4;
这样就可以了.
Save and Restore 配置
canvas 就是一个画布, 它是一个操作, 一个操作, 叠加画上去的.
下笔前, 我们可以为每一次的操作做配置. 比如
translate (下笔的位置)
rotate, sclae (绘画的方向)
globalAlpha (透明度)
等等...
绘画完成后, 通常都需要一个 reset 配置. 毕竟下一次操作未必和上一次同样配置.
canvas 默认是没有 reset 的. 但我们可以通过 save 和 restore 做到这一点.
[canvas.width, canvas.height] = [image.width * 0.4, image.height * 0.4]; context.save(); context.globalAlpha = 0.5; context.restore(); context.drawImage(image, 0, 0, canvas.width, canvas.height);
最终没有透明, 因为在绘画前它就被 restore 了.
注: save and resotre 指的是配置, 而不是绘画的成果哦, 它不是拿来做 undo 了.
Background Color
canvas to jpeg by default background color 是 黑色
to png background color 是 transparent
set background color 的方法是
const ctx = canvas.getContext('2d')!; ctx.fillStyle = '#f00'; // 写 'red', hsl(0deg 100% 50%), rgb(255 0 0) 也可以,估计 CSS 语法都支持 ctx.fillRect(0, 0, canvas.width, canvas.height);
想清除的话是这样
ctx.clearRect(0, 0, canvas.width, canvas.height);
冷知识 – 当你 resize canvas,draw 就不见了
参考: Stack Overflow – HTML canvas - drawing disappear on resizing
画一个红色的正方形
const canvas = document.querySelector<HTMLCanvasElement>('.img')!; const ctx = canvas.getContext('2d')!; canvas.width = 100; canvas.height = 100; ctx.fillStyle = '#f00'; ctx.fillRect(0, 0, canvas.width, canvas.height);
效果
加上 resize
ctx.fillRect(0, 0, canvas.width, canvas.height); canvas.width = 100;
注意,哪怕我只是 set 回原本的值 100 也不行哦。
效果
红色正方形完全消失了