如何用 canvas 渲染 Web Excel 富文本

·  阅读 1175
如何用 canvas 渲染 Web Excel 富文本

我正在参加「码上掘金挑战赛」详情请看:码上掘金挑战赛来了!

在一些前端开发场景中,可能会遇到使用 canvas 来渲染文本,例如 web 表格应用,就是用 canvas 来渲染文本,如果大家去检查飞书、谷歌、石墨、腾讯表格可以发现它们都是用 canvas 来实现的。

这篇文章就来讲解如何在 canvas 中渲染和排版富文本。在介绍之前可以先点击下面链接,体验下最终的效果。

自动换行

在平时基于 DOM 的文本开发时,我们并不关心文本的自动换行,因为浏览器已经自动帮我们自己处理了文本自动换行,如下图所示。

在 canvas 中只有两个 API fillTextstrokeText 来绘制文本,它们并不能处理文本自动换行,渲染出来的文本都在一行,类似于 white-space: nowrap一样的效果。

在 canvas 中如果想让文本自动换行,需要手动测量每一个字符的大小,如果累计的字符的宽度超过容器的宽度,则换一行继续渲染。

canvas 中的 measureText API 可以用来测量文本的信息,它返回一个 TextMetrics 对象,签名如下所示。

interface TextMetrics {
  // x-direction
  readonly attribute double width; // advance width
  readonly attribute double actualBoundingBoxLeft;
  readonly attribute double actualBoundingBoxRight;

  // y-direction
  readonly attribute double fontBoundingBoxAscent;
  readonly attribute double fontBoundingBoxDescent;
  readonly attribute double actualBoundingBoxAscent;
  readonly attribute double actualBoundingBoxDescent;
  readonly attribute double emHeightAscent;
  readonly attribute double emHeightDescent;
  readonly attribute double hangingBaseline;
  readonly attribute double alphabeticBaseline;
  readonly attribute double ideographicBaseline;
};
复制代码

TextMetrics 中的 width 表示当前测量字符的宽度,fontBoundingBoxAscentfontBoundingBoxDescent 可以知道这一行的高度。

const text = 'abcdefg'

let maxWidth = 100
let lineWidth = 0
let w = 0
let line = ''
for (let c of text) {
    w = ctx.measureText(c).width
    if (totalWidth + w > maxWidth) {
        console.log(line)
        line = c
        lineWidth = w
    } else {
        line += c
        lineWidth += w
    }
}
复制代码

上面代码中测量每个字符的大小,如果超过 maxWidth 则换一行继续测量,这样就简单的实现了文本自动换行。

但是,还没完,如果上面这样处理会英文单词被折断的问题,如下图所示。

上图中的 figure、exist、viewed 等单词都被从中间折断了,这样会导致用户不方便阅读,或者产生歧义。

正确的换行方式应该如下图所示。

如果剩余空间存放不下一个单词的长度则进行换行。

所以在判断的时候还需要区分当前字符是不是属于当前单词的字符。要做到按单词维度来换行,首先要区分当前字符是不是一个断词字符。我们可以认为 unicode 小于 0x2E80 的都为拉丁字符(echart 中是小于等于 0x017F),在这个范围内我们还需要排除一些字符,比如空格、问号等字符。

浏览器判断是否是断词字符是非常复杂的,还会和当前字符的上下文来判断,比如单个 [ 不是,如果前面加上 ] 就是了,但是我们这里没有必要做的这么复杂。只需要判断字符是否大于 0x2E80,或者是空格、问号等字符,就认为字符是断词字符,我们可以很轻松的写下如下判断函数。

const breakCharSet = new Set(['?', '-', ' ', ',', '.'])
function isWordBreakChar(ch) {
  if (ch.charCodeAt(0) < 0x2e80) return breakCharSet.has(ch)
  return true
}
复制代码

接下来完善下自动换行的代码,如下所示。

const lines = []
let line = ''
let word = ''
let lineWidth = 0
let wordWidth = 0
for (let c of text) {
    const w = ctx.measureText(c)
    const inWord = !isWordBreakChar(c)
    
    if (lineWidth + wordWidth + w > maxWidth) { // 如果超长
        if (lineWidth) {
            lines.push(line)
            line = ''
            lineWidth = 0
            
            if (wordWidth + w > maxWidth) {
                if (wordWidth) {
                    lines.push(word)
                    word = c
                    wordWidth = w
                }
                if (w > maxWidth) {
                    lines.push(c)
                    word = ''
                    wordWidth = 0
                }
            } else if (!inWord) {
              line += (word + c)
              lineWidth += (wordWidth + w)
              word = ''
              wordWidth = 0
            } else {
              word += ch
              wordWidth += w 
            }
            
        } else if (wordWidth) {
            lines.push(word)
            word = c
            wordWidth = w
        } else { // 如果容器宽度小于一个字符
            lines.push(c)
        }
    } else if (inWord) { // 如果属于一个单词
        word += ch
        wordWidth += w
    } else { // 如果不是一个单词
        line += (word + c)
        lineWidth += (wordWidth + w)
        word = ''
        wordWidth = 0
    }

}
复制代码

可以发现相比之前的简单换行,按单词换行复杂多了,因为我们需要判断很多边界情况,例如要一个单词换行,但是当容器宽度小于一个单词长度时,又要强行中断,在或者容器宽度小于一个字符时,需要一个字符一行。

富文本

了解了文本的自动换行,接下来再来看看如何实现 canvas 富文本渲染。在渲染之前我们首先定义好富文本的数据机构,如下所示。

interface Rich {
    start: number; // 开始字符(包含)
    end: number; // 结束字符(不包含)

    fontFamily?: string; // 字体
    fontSize?: number; // 字体大小
    bold?: boolean; // 是否加粗
    italic?: boolean; // 是否倾斜

    color?: string; // 颜色

    underline?: boolean; // 下划线
    lineThough?: boolean; // 删除线
}
复制代码

Rich 接口定义了原文本 startend 范围内的样式,这里一共定义了 7 种富文本样式,前 4 个可以用 canvas 中的 font 来实现,颜色可以用 fillStyle,而下划线和删除线则需要我们自己来实现,在特定位置画一条横线。

接下来再来定义下一个文本的数据结构,如下所示。

interface TextData {
    width: number; // 容器宽度
    text: string; // 要渲染的文本
    rich?: Rich[] // 当前文本的富文本样式
}
复制代码

富文本的自动换行会比上面介绍的自动换行还要复杂一点,因为一行文字中可能存在某个字符字体大小非常大,把其他字符挤下去,而且它还会影响行高,每行的行高也可能是不一致的。

我们 measureText 也需要做些改变才能准确测量出字符宽高,代码如下所示。

function getFont(r) {
  return `${r.italic ? 'italic' : ''} ${r.bold ? 'bold' : ''} ${r.fontSize || 16}px ${r.fontFamily || 'sans-serif'}`.trim()
}

function measureText(str, font) {
  ctx.font = font
  return ctx.measureText(str)
}
复制代码

测量字体时先设置字体的 font 再来测量,因为影响字符宽高的只有 font 属性。

接下来我们还需要设计 3 个类来帮助我们理解,分别是 TextCellTextLineTextToken

TextCell 是文本容器,它拥有多个 TextLineTextLine 是一个行文本,它包含多个 TextTokenTextToken 是是个文本片段,这一个文本片段的样式要是一样的(属于同一个 Rich)。

接下来我们需要将整个文本打散,变成上面我们提到的文本 token,代码如下所示。

let prevEnd = 0
for (let i = 0, r; i < richLen; ++i) {
  r = rich[i] // 富文本配置
  if (prevEnd < r.start) {
     // 纯文本
    flush(parseText(text.slice(prevEnd, r.start), x, maxWidth))
  }

  // 富文本
  flush(parseText(text.slice(r.start, r.end), x, maxWidth, r))
  prevEnd = r.end
}
复制代码

其中的 parseText 是上一章节中介绍的自动换行,它会返回一个个 TextToken,篇幅有限,这里就只贴相关代码,详细代码请查看码上掘金。

flush 是创建 TextLine 如果当前文本长度超了的话,另外它还会修改 TextToken 的高度,比如先解析字体比较小的 TextToken,如果后面又遇到这一行中字号更大的 TextToken 则需要手动修改之前 TextToken 的高度。

相关代码如下所示。

let prevEnd = 0
let x = 0
let j = 0
let len = 0
let line = []
let lineHeights = []
const lines = []

const flushLine = () => {
  lines.push(new TextLine(line, Math.max.apply(null, lineHeights))) // 修改行高
}
const flush = (info) => {
  j = 0
  while (info.tokens[j]?.x) j++
  len = info.tokens.length
  if (j < len) {
    if (line.length) { // 说明当前 TextToken 超了一行
      line.push(...info.tokens.slice(0, j))
      if (j) lineHeights.push(info.lineHeight)
      flushLine() // 完成一行
      line = []
      lineHeights = []
    }

    if ((len - j - 1) > 0) {
      for (let l = len - 1; j < l; ++j) { // 每一个 TextToken 就是一行
        lines.push(new TextLine([info.tokens[j]], info.lineHeight))
      }
    }

    line.push(info.tokens[len - 1]) // 保留最后一个
  } else {
    line.push(...info.tokens)
  }

  lineHeights.push(info.lineHeight)
  x = info.x // 一下个代解析片段的起始 x
}
复制代码

上面代码中是判断解析好的 TextToken,如果长度超了一行,则修改之前这一行 TextToken 的高度为最大高度。

另外还需保存最新一行已解析的宽度,就是上面代码中的 x。因为接下来解析新的文本是需要从 x 宽度之后来计算的。

渲染

有了上面计算好的信息,要将文本渲染出来就非常简单直接,代码如下所示。

function render(cellData) {
  const cell = new TextCell(cellData)
  ctx.save();

  ctx.strokeRect(0, 0, cell.width, cell.height);

  ctx.beginPath();
  ctx.rect(0, 0, cell.width, cell.height);
  ctx.clip();

  let dx = 4 // padding
  let dy = 0

  cell.lines.forEach(l => {
    l.tokens.forEach(t => {
      ctx.font = t.style.font
      ctx.strokeStyle = ctx.fillStyle = t.style.color || '#000'
      ctx.fillText(t.text, t.x + dx, l.y + dy) // 渲染文字

      if (t.style.underline) { // 渲染下划线
        ctx.beginPath();
        ctx.moveTo(t.x + dx, l.y+3 + dy); 
        ctx.lineTo(t.x + t.width + dx, l.y+3 + dy);
        ctx.stroke(); 
      }
      if (t.style.lineThough) { // 渲染删除线
        ctx.beginPath();
        ctx.moveTo(t.x + dx, l.y - t.actualHeight / 2 + dy); 
        ctx.lineTo(t.x + t.width + dx, l.y - t.actualHeight / 2 + dy);
        ctx.stroke();
      }
    })
  })

  ctx.restore();
}
复制代码

上面代码遍历每一个 TextToken,设置样式并渲染文字,如果有下划线或删除线,则再画一根线即可。

总结

这篇文章主要讲解了如何使用 canvas 来渲染富文本和富文本的自动换行,原理是使用 measureText API 来测量每个字符的宽高,并且判断当前字符是不是属于同一个单词,如果超过长度则进行换行,对与富文本我们还需要判断每个 TextToken 的高度,测量完一行后还需要修改这一行中每个 TextToken 的高度,计算好各种信息后,最后只用读取这些信息进行渲染即可。

这篇文章的中的计算代码都是没有经过性能优化的,如果渲染大量的数据可能性能很慢,下篇文章将讲解如何进行高性能的 canvas 渲染。

在线体验:

收藏成功!
已添加到「」, 点击更改