在一些前端开发场景中,可能会遇到使用 canvas 来渲染文本,例如 web 表格应用,就是用 canvas 来渲染文本,如果大家去检查飞书、谷歌、石墨、腾讯表格可以发现它们都是用 canvas 来实现的。
这篇文章就来讲解如何在 canvas 中渲染和排版富文本。在介绍之前可以先点击下面链接,体验下最终的效果。
自动换行
在平时基于 DOM 的文本开发时,我们并不关心文本的自动换行,因为浏览器已经自动帮我们自己处理了文本自动换行,如下图所示。
在 canvas 中只有两个 API fillText
和 strokeText
来绘制文本,它们并不能处理文本自动换行,渲染出来的文本都在一行,类似于 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
表示当前测量字符的宽度,fontBoundingBoxAscent
加 fontBoundingBoxDescent
可以知道这一行的高度。
const text = 'abcdefg'let maxWidth = 100let lineWidth = 0let w = 0let 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}
接下来完善下自动换行的代码,如下所示。
代码语言:javascript复制const lines = []let line = ''let word = ''let lineWidth = 0let wordWidth = 0for (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 富文本渲染。在渲染之前我们首先定义好富文本的数据机构,如下所示。
代码语言:javascript复制interface Rich { start: number; // 开始字符(包含)
end: number; // 结束字符(不包含)
fontFamily?: string; // 字体
fontSize?: number; // 字体大小
bold?: boolean; // 是否加粗
italic?: boolean; // 是否倾斜
color?: string; // 颜色
underline?: boolean; // 下划线
lineThough?: boolean; // 删除线}
Rich
接口定义了原文本 start
到 end
范围内的样式,这里一共定义了 7 种富文本样式,前 4 个可以用 canvas 中的 font
来实现,颜色可以用 fillStyle
,而下划线和删除线则需要我们自己来实现,在特定位置画一条横线。
接下来再来定义下一个文本的数据结构,如下所示。
代码语言:javascript复制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 个类来帮助我们理解,分别是 TextCell
、TextLine
和 TextToken
。
TextCell
是文本容器,它拥有多个 TextLine
,TextLine
是一个行文本,它包含多个 TextToken
,TextToken
是是个文本片段,这一个文本片段的样式要是一样的(属于同一个 Rich)。
接下来我们需要将整个文本打散,变成上面我们提到的文本 token,代码如下所示。
代码语言:javascript复制let prevEnd = 0for (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
的高度。
相关代码如下所示。
代码语言:javascript复制let prevEnd = 0let x = 0let j = 0let len = 0let 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
宽度之后来计算的。
渲染
有了上面计算好的信息,要将文本渲染出来就非常简单直接,代码如下所示。
代码语言:javascript复制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 渲染。
在线体验: