Canvas没有提供API来绘制富文本内容,需要我们把富文本拆分成一个个普通文本来绘制。首先我们了解一下canvas为文本提供了哪些api。
这里通过一个例子来了解一下文本的绘制,详细使用说明可以通过 HTML Canvas 参考手册了解。
绘制普通文本
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);
}
- font 属性由
font-style | font-variant | font-weight | font-size/line-height | font-family构成。最后font-size和font-family是必须的,并且顺序固定。其他属性是可选且顺序可变。 - 目前浏览器环境
line-height设置无效,浏览器会强制设置未normal。富文本中line-height效果需要通过计算实现。
绘制富文本
解析html
- html多个空格合并成一个,但有些情况\n需换行处理
- 首尾空格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
}
逐行绘制
- 计算行距的时候,上半行距向上取整,下半行距向下取整,因为大多数字体都是偏下的。
- 行距 = 行高 - fontSize (一般文字字数的高度等于fontSize)
- 不同的word-wrap、word-break、line-break计算换行逻辑会有所不同
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;
})
}