三羊

三羊的小站

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

July 27, 2018/「 canvas / Edit on Github ✏️

这篇是学习和回顾 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 区域。

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

drawImage1

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

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

drawImage2

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

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

drawImage3

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

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

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

drawImage4

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

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

drawImage5

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
// 指定目标canvas区域的宽度
let targetWidth = canvasWidth
// 计算出目标canvas区域的高度
let targetHeight = (imgHeight * targetWidth) / imgWidth
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)

getImageData1

可以看到,我们的 canvas 默认宽高是 300*150,通过ctx.getImageData获取整个 canvas 区域的像素数据值,得到的ImageData的设备像素的宽高也是 300*150 ,Imagedata.data 的数组的长度是 180000,这个是因为,这个 imgData 的像素数是 300*150,而每个像素是由 4 个分量表示的,所以3001504=180000300*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
    // 指定目标canvas区域的宽度
    let targetWidth = canvasWidth
    // 计算出目标canvas区域的高度
    let targetHeight = (imgHeight * targetWidth) / imgWidth
    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]
  }
}

putImageData1

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

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

putImageData2

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

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

putImageData3

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

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

putImageData4

通过取 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 所有其他的绘制,而不仅仅是文本,所以在这里,就不详细讨论了。

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

strokeText1

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

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

strokeText2

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

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

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

小结

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

若有收获,小额鼓励