Canvas——绘制富文本

1,738 阅读1分钟

Canvas没有提供API来绘制富文本内容,需要我们把富文本拆分成一个个普通文本来绘制。首先我们了解一下canvas为文本提供了哪些api。 image.png 这里通过一个例子来了解一下文本的绘制,详细使用说明可以通过 HTML Canvas 参考手册了解。

绘制普通文本

在线DEMO预览

image.png

function draw() {
    //...省略获取下拉框值
    // draw
    let canvas = document.getElementById("canvas")
    let ctx = canvas.getContext('2d')
    ctx.clearRect(0, 0, 400, 200)
    ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
    ctx.textBaseline = textBaseline
    ctx.textAlign = textAlign
    ctx.fillText('你好,X x y!', 100, 100);
}
  1. font 属性由 font-style | font-variant | font-weight | font-size/line-height | font-family 构成。最后font-sizefont-family是必须的,并且顺序固定。其他属性是可选且顺序可变。
  2. 目前浏览器环境line-height设置无效,浏览器会强制设置未normal。富文本中line-height效果需要通过计算实现。

绘制富文本

在线DEMO预览

image.png

解析html

  1. html多个空格合并成一个,但有些情况\n需换行处理
  2. 首尾空格html不渲染
  //parse html
  //@return {width,height,contents:[{style,content}]}
  function parseHtml(html) {
    let div = document.createElement("div")
    //多个空格合并按一个处理
    div.innerHTML = html.replace(/\s+/g, " ") //todo 有些情况\n需换行处理
    let root = div.firstElementChild
    let { width, height } = root.style
    let textArr = []
    function _each(n, style = {}) {
      for (let i = 0; i < n.childNodes.length; i++) {
        let child = n.childNodes[i]
        if (child.nodeType == 3) {
          textArr.push({ style, content: child.nodeValue })
        } else {
          _each(child, Object.assign({}, style, getNodeStyle(child)))
        }
      }
    }
    _each(root, getNodeStyle(root))
    //去掉前面空格
    while(textArr.length > 0 && /^\s$/.test(textArr[0].content)){
      textArr.splice(0,1)
    }
    let list = []
    textArr.forEach(item => {
      list.push(...item.content.split("").map(c => { return { style: Object.assign({}, DEFAULT, item.style), content: c } }))
    })
    width = width ? parseFloat(width) : 0;
    height = height ? parseFloat(height) : 0;
    return { contents: list, width, height, textAlign: root.style.textAlign }
  }
  
  const DEFAULT = {
    fontFamily: "",
    fontStyle: "normal",
    fontSize: 12,
    fontWeight: 'normal',
    color: "#000000",
    textAlign: 'left',
    lineHeight: 1.1,
    letterSpacing: 0
  }
  const keys = ['font-family', 'font-size', 'font-weight', 'font-style', 'text-align', 'color', 'letter-spacing', 'line-height']
  function getNodeStyle(n) {
    const style = n.style
    let ret = {}
    keys.filter(k => style[k] != null && style[k] != '').forEach(k => {
      ret[k.replace(/-(\w)/g, function(_, c) { return c ? c.toUpperCase() : ''; })] = style[k]
    })
    return ret
  }

逐行绘制

  1. 计算行距的时候,上半行距向上取整,下半行距向下取整,因为大多数字体都是偏下的。
  2. 行距 = 行高 - fontSize (一般文字字数的高度等于fontSize)
  3. 不同的word-wrap、word-break、line-break计算换行逻辑会有所不同
  4. measureText计算宽度依赖于font属性
function drawRichText() {
    let { width, height, textAlign, contents } = parseHtml(document.getElementById('demo').outerHTML)
    let canvas = document.getElementById('canvas')
    let ctx = canvas.getContext("2d")
    width = width || canvas.width;
    //计算需要多少行,以及每行的行高
    //不同的word-wrap、word-break、line-break计算换行逻辑会有所不同
    let lines = [], list = [],x = 0,  y = 0;
    contents.forEach((n, i) => {
      let s = n.style
      let fontSize = parseFloat(s.fontSize)
      let lineHeight = Math.round(s.lineHeight * fontSize) 
      ctx.font = `${s.fontStyle} ${s.fontWeight} ${fontSize}px ${s.fontFamily}`
      let w = ctx.measureText(n.content).width //计算当前宽度
      n.width = w; //字体宽度
      n.lineHeight = lineHeight; //行高
      n.space = lineHeight - fontSize //行距 = 行高 - fontSize 
      s.letterSpacing = parseFloat(s.letterSpacing) //字间距
      n.style = s;
      list.push(n)
      let next = contents[i + 1];
      if (!next) {
        lines.push(list)
        return
      }
      //判断下一个字符是否放得下
      x = x + w + s.letterSpacing;
      if (x + ctx.measureText(next.content).width > width) {
        lines.push(list)
        list = []
        x = 0;
      }
    })
    //逐行绘制
    x = 0;
    y = 0;
    lines.forEach((line, i) => {
      const { lineWidth, maxLineHeight, maxSpace } = line.reduce((total, cur) => {
        return {
          maxLineHeight: Math.max(total.maxLineHeight || 0, cur.lineHeight), //行内最大行高
          maxSpace: Math.max(total.maxSpace || 0, cur.space), //行内最大行距
          lineWidth: (total.lineWidth || 0) + cur.width + cur.style.letterSpacing //行内元素总宽度
        }
      }, {})
      let offsetX = 0
      if (textAlign == 'center') {
        offsetX = (width - lineWidth) / 2
      } else if (textAlign == 'right') {
        offsetX = width - lineWidth
      }

      line.forEach(n => {
        let s = n.style
        let fontSize = parseFloat(s.fontSize);
        let lineHeight = Math.round(s.lineHeight * fontSize)
        //draw
        ctx.fillStyle = s.color
        ctx.font = `${s.fontStyle} ${s.fontWeight} ${fontSize}px ${s.fontFamily}`
        ctx.textBaseline = 'bottom';
        let diff = maxLineHeight - n.lineHeight;
        //todo,依赖vertical-align的值,这里约定bottom
        let tmpY = 0
        if (diff != 0) {
          tmpY = y + maxLineHeight - Math.floor(maxSpace / 2)
        } else {
          tmpY = y + fontSize + Math.ceil(n.space / 2) + diff; //上半行距向上取整,下半行距向下取整,因为大多数字体都是偏下的
        }
        x = x ? x : offsetX;
        ctx.fillText(n.content, x, tmpY);
        x = x + n.width + s.letterSpacing
      })
      x = 0;
      y = (i + 1) * maxLineHeight;
    })
  }