canvas核心技术-如何绘制图片和文本

7,217 阅读13分钟

这篇是学习和回顾canvas系列笔记的第三篇,完整笔记详见:canvas 核心技术

通过上一篇canvas核心技术-如何绘制图形的学习,我们知道了如何绘制任意多边形以及图片的填充规则。在canvas中应用比较多的还有绘制图片和文本。这篇文章,我们就来详细聊聊图片和文本的绘制。

图片

在canvas中,我们可以把一张图片直接绘制到canvas上,跟使用img标签类似,不同的是,图片是绘制到canvas画布上的,而非独立的html元素。canvas提供了drawImage方法来绘制图片,这个方法可以有三种形式的用法,如下,

  • void drawImage(image,dx,dy);直接将图片绘制到指定的canvas坐标上,图片由image传入,坐标由dx和dy传入。
  • void drawImage(image,dx,dy,dw,dh);同上面形式,只不过指定了图片绘制的宽度和高度,宽高由dw和dh传入。
  • void drawImage(image,sx,sy,sw,sh,dx,dy,dw,dh);这个是最复杂,最灵活的使用形式,第一参数是待绘制的图片元素,第二个到第五个参数,指定了原图片上的坐标和宽高,这部分区域将会被绘制到canvas中,而其他区域将忽略,最后四个参数跟形式二一样,指定了canvas目标中的坐标和宽高。

根据参数个数,我们会分别调用不同形式的drawImage,第一种形式最简单,就是将原图片直接绘制到目标canvas指定坐标处,图片宽高就是原图片宽高,不会缩放。第二种形式呢,指定了目标canvas绘制区域的宽高,那么图片最终被绘制在canvas上的宽高被固定了,图片会被缩放,如果指定的dw和dh与原图片的宽高不是等比咧的,图片会被压缩或者拉伸变形。第三种形式,分别指定了原图片被绘制的区域和目标canvas中的区域,通过sx,sy,sw,sh我们可只选择原图片中某一部分区域,也可以指定完整的图片,通过dx,dy,dw,dh我们待绘制的目标canvas区域。

let img = document.createElement('img'); //创建img元素
img.src = './learn9/google.png'; //指定img的src
img.addEventListener(
  'load',
  () => {
    ctx.drawImage(img, 0, 0); // 将img元素调用drawImage(img,dx,dy)绘制出来
  },
  false,
);

上面这个示例,这张Google图片的原始大小是544*184,而canvas区域的大小是默认的300*150。我们调用了第一种形式,直接将图片绘制到canvas的坐标原点处,图片没有被缩放,超出了canvas区域,超出的部分,会被canvas忽略的。有一点需要注意的是,我是在图片的onload事件中才开始绘制的,因为图片没有加载完毕,直接绘制图片是无效的。下面的代码示例,我都将只贴出onload事件里的代码,图片加载部分代码都相同,就省略了。

let canvasWidth = canvas.width; //获取canvas宽度
let canvasHeight = canvas.height; //获取canvas高度
ctx.drawImage(img, 0, 0, canvasWidth, canvasHeight); 

我们把目标canvas区域指定为canvas的宽高,图片总是会被绘制在整个canvas中,同时也可以看到绘制出来的图片变形了。我们可以通过计算出原图片的宽高比,根据canvas目标区域的宽度来计算出canvas目标区域的高度,或者根据canvas目标区域的高度来计算出canvas目标区域的宽度。

let imgWidth = img.width; //获取图片的宽度
let imgHeight = img.height; //获取图片的高度
let targetWidth = canvasWidth; //指定目标canvas区域的宽度
let targetHeight = (imgHeight * targetWidth) / imgWidth; //计算出目标canvas区域的高度
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);

从图可以看到,根据图片宽高比计算出来的目标canvas区域,最终,图片绘制出来的效果是等比例缩放,没有变形。

我们再来看看最为复杂,且最为灵活的第三种方式。使用这种方式,我们可以把Google这张图片中的红色的那个o部分绘制出来。

ctx.drawImage(img, 143, 48, 90, 90, 0, 0, 90, 90);

Google这张图中,红色字母o在原图片中的坐标是(143,48),宽高是90*90,我们简单的把这个字母绘制在了canvas的(0,0)坐标处,宽高也是90*90。可以再来复杂点,把这个红色的字母o,让它的高度跟canvas的高度一样,且等比例放大宽度,且圆心正好在canvas中心,实现如下,

let oWidth = 90; //获取字母o的宽度
let oHeight = 90; //获取字母o的高度
let targetHeight = canvas.height; //指定目标canvas区域的高度
let targetWidth = (oWidth * targetHeight) / oHeight; //计算出目标canvas区域的宽度
let targetX = (canvas.width - targetWidth) / 2; //移动目标canvas坐标X
ctx.drawImage(img, 143, 48, oWidth, oHeight, targetX, 0, targetWidth, targetHeight);

drawImage返回的第一参数image,不仅可以是图片元素,实际上还可以是canavs元素,video元素。常见的离屏canvas的使用,依就是将离屏不可见的canvas绘制到当前显示屏幕canvas上。离屏幕canvas这一部分将会在后续游戏部分中说到,这里不详细说了。

图像像素

跟图片绘制有关的函数还有3个,它们分别是getImageDataputImageDatacreateImageData。这些函数是直接可以改变图像中某一个具体的像素值,从而可以对图片做一些操作,比如滤镜。

我们先来看看getImageData方法,它的调用方式是let imgData = ctx.getImageData(sx,sy,sw,sh),接受四个参数,表示canvas区域的某一个矩形区域,这个矩形区域的左上角坐标是(sx,sy),宽高是sw 和sh,它的返回值是一个ImageData类型的对象,包含的属性有widthheightdata

  • ImageData.width,无符号长整型,表示这个图像区域的像素的宽度。

  • ImageData.height,无符号长整型,表示这个图像区域的像素的高度。

  • ImageData.data,一个Uint8ClampedArray数组,数组里每4个单元,表示一个像素值。一个像数值用RGBA表示的,这4个单元分别表示R,G,B,A,表示意思是红,绿,蓝,透明度,取值范围是0~255。

需要注意的是,如果我们在调用ctx.getImageData(sx,sy,sw,sh),参数表示的矩形区域超出了canvas的区域,那么超出的部分将是用黑色的透明度为0的RGBA值表示,也就是(0,0,0,0)。

let imgWidth = img.width; //获取图片的宽度
let imgHeight = img.height; //获取图片的高度
let targetWidth = canvasWidth; //指定目标canvas区域的宽度
let targetHeight = (imgHeight * targetWidth) / imgWidth; //计算出目标canvas区域的高度
ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
let imgData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
console.log(`canvas.width = ${canvasWidth}`);
console.log(`canvas.height = ${canvasHeight}`);
console.log(imgData);

可以看到,我们的canvas默认宽高是300*150,通过ctx.getImageData获取整个canvas区域的像素数据值,得到的ImageData的设备像素的宽高也是300*150 ,Imagedata.data 的数组的长度是180000,这个是因为,这个imgData的像素数是300*150,而每个像素是由4个分量表示的,所以300*150*4 = 180000了。

当我们通过getImageData得到canvas某一个矩形区域的像素数据之后,我们可以通过改变这个imageData.data数组里的颜色分量值,再将改变后的ImageData通过putImageData方法绘制到canvas上。putImageData的用法有2种调用形式,如下,

  • ctx.putImageData(imgData,dx,dy),这种方式,将imgData绘制到canvas区域(dx,dy)坐标处,绘制到canvas的区域的矩形大小就是imgData的矩形的大小。
  • ctx.putImageData(imgData,dx,dy,dirtyX,dirtyY,dirtyW,dirtyH),不仅指定了canvas区域(dx,dy),也指定了imgData脏数据区域的(dirtyX,dirtyY)和宽高dirtyW,dirtyH。这种形式,可以只将imgData种某一块区域绘制到canvas上。
let canvasWidth = canvas.width;
let canvasHeight = canvas.height;
let img = document.createElement('img');
img.src = './learn9/google.png';
img.addEventListener(
 'load',
 () => {
   let imgWidth = img.width; //获取图片的宽度
   let imgHeight = img.height; //获取图片的高度
   let targetWidth = canvasWidth; //指定目标canvas区域的宽度
   let targetHeight = (imgHeight * targetWidth) / imgWidth; //计算出目标canvas区域的高度
   ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
   //操作ImageData像素数据
   let imgData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
   oprImageData(imgData, (r, g, b, a) => {
     if (a === 0) {
       return [r, g, b, 255]; //将透明的黑色像素值改变为不透明
     }
     return [r, g, b, a];
   });
   //将imgData绘制到canvas的中心。超出canvas区域将被自动忽略
   ctx.putImageData(imgData, canvasWidth / 2, canvasHeight / 2);
 },
 false,
);

// 遍历像素数据
function oprImageData(imgData, oprFunction) {
 let data = imgData.data;
 for (let i = 0, l = data.length; i < l; i = i + 4) {
   let pixel = oprFunction(data[i], data[i + 1], data[i + 2], data[i + 3]);
   data[i] = pixel[0];
   data[i + 1] = pixel[1];
   data[i + 2] = pixel[2];
   data[i + 3] = pixel[3];
 }
}

上面,我们遍历了ImageData中data数组,并将透明度为0的像素值的透明度变为1(255/255=1)。在遍历像素数组时,我们每便利一次,i 的值加4,这个是因为一个像素值是用4个数组单元值表示的,分别为R,G,B,A,我们可以只改变某一个像素值的某一个分量值,例如透明度。

ctx.putImageData(imgData, canvasWidth / 2, canvasHeight / 2, 79, 27, 50, 50);

我们通过指定了ImageData中脏数据区域,只绘制了红色字母o,其他部分忽略。上面在调用putImageData之前,我们通过遍历像素数据改变了部分像素值的透明度,这种可以操作像素值的方式,在图像处理等领域是非常有用的,例如常见的图像灰度和反相颜色等。

//操作ImageData像素数据
let imgData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
ctx.clearRect(0, 0, canvasWidth, canvasHeight); //清除canvas
oprImageData(imgData, (r, g, b, a) => {
    return [255 - r, 255 - g, 255 - b, a]; //反相颜色
});
ctx.putImageData(imgData, 0, 0);

将颜色分量的RGB值都用255减去原颜色分量值,可以看到,Google每个字母的颜色都与原图片的颜色不一样了。这个在改变每个颜色分量的值,用不通的逻辑计算,就可以得到不同的处理后的图片。

//操作ImageData像素数据
let imgData = ctx.getImageData(0, 0, canvasWidth, canvasHeight);
ctx.clearRect(0, 0, canvasWidth, canvasHeight); //清除canvas
oprImageData(imgData, (r, g, b, a) => {
    let avg = (r + g + b) / 3;
    return [avg, avg, avg, a]; //灰度
});
ctx.putImageData(imgData, 0, 0);

通过取RGB的平均值,原图片的每个字母都是灰色的了,当然,在计算的时候,可以给每个分量加一个系数,例如公式let avg = 0.299r + 0.587g + 0.114b,具体应用可以查看Grayscale

最后来看看createImageData,这个很好理解了,就是创建一个ImageData 对象了,有两种形式,如下,

  • ctx.createImageData(width,height),可以指定宽高,创建一个ImageData对象,ImageData.data中的像素值都是一个透明的黑色,也就是(0,0,0,0)。
  • ctx.createImageData(imgData),可以指定一个已经存在的ImageData 对象来创建一个新的ImageData对象,新创建的ImageData对象的宽高与参数中的ImageData 的宽高一样,但是像素值就不一样了,新创建出来的ImageData的像素值都是透明的黑色,也就是(0,0,0,0)。

文本

在canvas中,我们不仅可以绘制图形,图片,还可以绘制文本。绘制文本比较简单了,先设置当前ctx的画笔的文本样式,例如,字体大小,字体样式,对其方式等,跟css中比较相似。

跟文本相关的方法有三个,如下,

  • strokeText(text,x,y,maxWidth?),用描边的形式绘制指定的文本text,其中也指定了绘制的坐标(x,y), 还有最后一个可选参数,最大的宽度,如果所绘制的文本超过了指定的maxWidth,则文本会按照最大的宽度来绘制,那么文字之间的间距就将减少,文字可能被压缩。
  • fillText(text,x,y,maxWidth?),同strokeText一样,只不过,是用填充的形式绘制文本,其参数含义一样。
  • measureText(text),在当前的文字样式下,测量绘制文本text会占据的宽度值,返回一个对象,这个对象有一个width属性。主要注意的是,必须先设置文本样式,再来测量才是准确的。

跟文本直接相关的属性设置,如下,

  • font,同css中含义一样,可以指定文本的字体大小,字体集,字体样式等。但在canvas中,line-height被强制设置为normal,会忽略其他设置的值。
  • textAlign,设置文本的水平对其方式,可选值有:leftrightcenterstartend。默认值是start。各个含义参见textAlign取值
  • textBaseline,设置文本的垂直对齐方式,可选值有:tophangingmiddlealphabeticideographicbottom。默认值是alphabetic。各个含义参见textBaseline取值

当然了,还有一些其他的属性也会影响到文本最终绘制出来的效果,比如给当前ctx添加阴影效果,或者设置fillStyle的样式可以是图片或者渐变等。这些算是全局的属性设置,会影响到canvas所有其他的绘制,而不仅仅是文本,所以在这里,就不详细讨论了。

let textAligns = ['left', 'right', 'center', 'start', 'end']; //textAlign的取值
let colors = ['red', 'blue', 'green', 'orange', 'blueviolet']; //描边颜色
ctx.font = '18px sans-serif'; //设置font
for (let [index, textAlign] of textAligns.entries()) {
  ctx.save();
  ctx.textAlign = textAlign; // 设置textAlign
  ctx.strokeStyle = colors[index]; //设置描边颜色
  ctx.strokeText(textAlign, width / 2, 20 + index * 30); //使用描边绘制文本
  ctx.restore();
}

我们把textAlign的各个属性全都设置了一遍,看到startleft的效果一样,endright的效果一样,这个是因为startend是与当前本地文字开始方向有关的,如果是左到右开始,那么startleft一样,而如果是右到左开始,那么start是与right效果一样了。

let textBaselines = ['top', 'hanging', 'middle', 'alphabetic', 'ideographic', 'bottom'];
let colors = ['red', 'blue', 'green', 'orange', 'blueviolet', 'cyan']; //描边颜色
ctx.font = '18px sans-serif'; //设置font
for (let [index, textBaseline] of textBaselines.entries()) {
  ctx.save();
  ctx.textBaseline = textBaseline; // 设置textBaseline
  ctx.strokeStyle = colors[index]; //设置描边颜色
  ctx.strokeText('abj', 10 + index * 50, height / 2); //使用描边绘制文本
  ctx.restore();
}

我们又把textBaseline的各个值全设置了一遍,看到的效果如上图。用到最多的应该是topmiddlealphabeticbottom了,其中默认值是alphabetic

measureText在实际业务中也是用到比较多的一个方法了,这个方法可以测量出在当前设置的文本样式下,绘制指定的text会占据的宽度。特别是在绘制表格数据,或者一些分析图时,需要绘制说明提示性文本,但是又想根据当前鼠标位置来决定文本绘制的坐标,以免超出canvas可见区域。这个方法使用比较简单,会返回一个带有width属性的对象,这个width属性值就是测量出来的结果。在canvas没有测量文本高度的方法,然而,在实际时,常常会以W字母测量出来的宽度值加上一点点,就可以大致认为是当前文本的高度值了。

ctx.font = '18px sans-serif'; //设置font,一定得先设置font属性,才能测量准确
let textWidth = ctx.measureText('W').width;
let textHeight = textWidth + textWidth / 6;
console.log(`当前文本W的宽度:${textWidth}`);
console.log(`当前文本W的高度:${textHeight}`);

小结

这篇文章主要是学习了canvas中如何使用drawImage来绘制图片,以及如何使用getImageDataputImageData来对图像像素值做处理,比如常见的图片灰度处理,或者反相颜色等。也回顾了在canvas中绘制文本的一些相关方法和属性,这些知识在css中比较类似,理解起来也比较容易和简单。