我正在参与「码上挑战赛」概况请看:码上挑战赛来了!

在一些前端开发场景中,或许会遇到运用 canvas 来烘托文本,例如 web 表格应用,便是用 canvas 来烘托文本,假如咱们去检查飞书、谷歌、石墨、腾讯表格能够发现它们都是用 canvas 来完成的。

这篇文章就来解说如安在 canvas 中烘托和排版富文本。在介绍之前能够先点击下面链接,体会下终究的效果。

主动换行

在平时根据 DOM 的文本开发时,咱们并不关心文本的主动换行,由于浏览器现已主动帮咱们自己处理了文本主动换行,如下图所示。

怎么用 canvas 烘托 Web Excel 富文本

在 canvas 中只要两个 API fillTextstrokeText 来制作文本,它们并不能处理文本主动换行,烘托出来的文本都在一行,类似于 white-space: nowrap相同的效果。

怎么用 canvas 烘托 Web Excel 富文本

在 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 则换一行持续丈量,这样就简略的完成了文本主动换行。

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

怎么用 canvas 烘托 Web Excel 富文本

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

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

怎么用 canvas 烘托 Web Excel 富文本

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

所以在判别的时分还需求区别当时字符是不是属于当时单词的字符。要做到按单词维度来换行,首要要区别当时字符是不是一个断词字符。咱们能够以为 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 烘托。

在线体会: