动态文本绘制到指定尺寸Canvas,字体大小自适应
背景
接到一个需求,用户上传字体,根据用户上传的字体生成一张预览图。转换成编码思路就是:给定一个固定尺寸的画布(canvas),根据画布尺寸实现文字字体大小自适应,填满画布。
而此时本人的canvas水平仅限于拼出这个单词。
一番 google 后踩了不少坑,下面我就来重现一下实现过程。
注意,这可能是你没看过的船新版本!!
为了方便赶时间的同学,这里先将demo源码直接贴出来,演示字体可以在我的同性交流地址上找到
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<style>
body {
display: grid;
place-items: center;
}
#demo-div {
display: inline;
background-color: skyblue;
position: relative;
}
#demo-div::after {
position: absolute;
content: '';
width: 0;
height: 50px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border-left: 1px dashed red;
}
#demo-div::before {
position: absolute;
content: '';
width: 200px;
height: 0;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border-top: 1px dashed purple;
}
.btn-list {
display: flex;
column-gap: 10px;
margin-bottom: 20px;
}
</style>
<body>
<canvas id="demo-canvas" style="width: 400px; height: 200px"></canvas>
<div class="btn-list">
<button id="times">Times</button>
<button id="hanab">Hanab</button>
<button id="magnetob">MAGNETOB</button>
</div>
<div id="demo-div">initial</div>
<script>
/**
* @description: 加载字体
* @param {string} fontName 字体名称
* @param {string} url 字体链接
*/
const loadFonts = async (fontName, url) => {
try {
const font = new FontFace(fontName, `url(${url})`);
await font.load();
document.fonts.add(font);
} catch (error) {
throw new Error('字体加载失败')
}
}
/**
* @description: 生成预览图
* @param {string} str 文本内容
* @param {string} width 预览图宽度
* @param {string} height 预览图高度
* @param {number} initFontSize 初始字体尺寸
* @param {string} initialTitle 字体名称
*/
const generatePreview = async (
str,
width,
height,
font,
fontUrl,
initFontSize,
) => {
try {
await loadFonts(font, fontUrl)
// 处理高分屏
const dpr = window.devicePixelRatio;
// const canvas = document.createElement('canvas');
const canvas = document.getElementById('demo-canvas');
canvas.width = width * dpr;
canvas.height = height * dpr;
const ctx = canvas.getContext('2d')
ctx.scale(dpr, dpr);
// 绘制背景
ctx.fillStyle = 'pink';
ctx.fillRect(0, 0, 400, 200);
ctx.fillStyle = 'black';
ctx.font = `normal ${initFontSize}px ${font}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 获取输入文本的实际高度
const getActualHeight = () => {
const { actualBoundingBoxAscent, actualBoundingBoxDescent } = ctx.measureText(str);
return actualBoundingBoxAscent + actualBoundingBoxDescent;
};
// 获取输入文本的实际宽度
const getActualWidth = () => {
const { actualBoundingBoxLeft, actualBoundingBoxRight } = ctx.measureText(str);
return actualBoundingBoxLeft + actualBoundingBoxRight;
};
/**
* @description: 二分法计算合适的字体尺寸
* @param {number} size 初始字体大小
* @param {number} max 预览图最大宽 / 高
* @param {function} culcalate 获取当前文本实际 宽 / 高 的计算函数
*/
const dichotomizeCulcalate = (size, max, culcalate) => {
let preResult;
let minSize = size;
let maxSize = size;
do {
preResult = culcalate();
if (preResult > max) {
maxSize = minSize;
minSize = Math.floor(minSize / 2);
} else {
minSize = Math.floor((maxSize - minSize) / 2) + minSize;
}
// 判断完当前尺寸后更新文本信息,进入下一次循环
ctx.font = `normal ${minSize}px ${font}`;
if (maxSize === minSize) break;
} while (preResult !== culcalate());
};
// 限制文字宽度
dichotomizeCulcalate(initFontSize, width, getActualWidth);
// 限制文字高度
dichotomizeCulcalate(Number(ctx.font.split('px').shift()), height, getActualHeight);
// 绘制文本
ctx.fillText(
str,
(width - getActualWidth()) / 2 + ctx.measureText(str).actualBoundingBoxLeft, // 修正部分字体不由水平中线(center)均分导致的位移
(height - getActualHeight()) / 2 + ctx.measureText(str).actualBoundingBoxAscent, // 修正部分字体不由垂直中线(middle)均分导致的位移
);
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve({
previewBlobUrl: createObjectURL(blob), // 预览图本地链接
previewBlob: blob,
});
} else {
reject(new Error('预览图生成失败'));
}
});
});
} catch (error) {
console.log(error)
}
};
// 初始字体大小
const INIT_FONTSIZE = 500;
// 画布宽度(预览图宽度)
const INIT_WIDTH = 400;
// 画布宽度(预览图高度)
const INIT_HEIGHT = 200;
const div = document.getElementById('demo-div')
document.getElementById('times').addEventListener('click', () => {
generatePreview('Times', INIT_WIDTH, INIT_HEIGHT, 'Times', 'Times.ttc', INIT_FONTSIZE)
div.innerText = 'Times'
div.style.fontFamily = 'Times'
})
document.getElementById('hanab').addEventListener('click', () => {
generatePreview('Hanab', INIT_WIDTH, INIT_HEIGHT, 'Hanab', 'Hanab.ttf', INIT_FONTSIZE)
div.innerText = 'Hanab'
div.style.fontFamily = 'Hanab'
})
document.getElementById('magnetob').addEventListener('click', () => {
generatePreview('MAGNETOB', INIT_WIDTH, INIT_HEIGHT, 'MAGNETOB', 'MAGNETOB.ttf', INIT_FONTSIZE)
div.innerText = 'MAGNETOB'
div.style.fontFamily = 'MAGNETOB'
})
</script>
</body>
</html>
实现
思路
实现字体上传,字体加载,设置一个较大的字体大小填满画布,再根据字体尺寸慢慢缩小字体大小,直至字体完全呈现在画布内。
我们可以先通过文字的宽来判定,当宽缩小到画布尺寸时判定高度是否溢出,如果溢出则继续缩小字体,否则当前即是合适字体大小。
字体上传
由于这一步与文章内容关系不大 (主要是我懒得写),我们这里用按钮的点击事件和本地文件简单模拟了字体上传的过程。
最后效果大致如下图,点击中间按钮模拟上传,加载文字,上面粉色部分是canvas区域,尺寸 400px * 200px,下面天蓝色部分则是对照用的 inline box,即普通的行内盒子
字体加载
这一步比较简单,可以通过浏览器提供的 FontFace 接口,注意部分字体浏览器加载不了,需要做容错处理
// 加载字体
const loadFonts = async (fontName, url) => {
try {
const font = new FontFace(fontName, `url(${url})`);
await font.load();
document.fonts.add(font);
} catch (error) {
throw new Error('字体加载失败')
}
}
或者通过动态注入 style 标签加载,但是这样需要额外再验证字体是否加载成功,推荐用第一种方法
// 注入字体样式标签
const insertFontStyle = (fontName, url) => {
const style = document.createElement('style');
style.innerHTML = `@font-face {
font-weight: normal;
font-style: normal;
font-family: "${fontName}";
src: url('${url}');
}`;
document.body.appendChild(style);
return style;
};
获取文本宽度
如果有搜索过相关内容的观众老爷肯定看到过 ctx.measureText(str).width 。通过这个 api 可以拿到绘制字体的宽度,然而🦢!这就是第一个坑!
ctx.measureText(str) 会返回一个 TextMetrics 对象,这个对象包含了文本相关的属性
MDN 上对于 TextMetrics.width 属性有两段描述,一个是在 TextMetrics ,这里写的是:
double 类型,使用 CSS 像素计算的内联字符串的宽度。基于当前上下文字体考虑
当时我看到这就觉得 width 就是字符串实际宽度了,其实并不是,另一段描述在这:
只读的 TextMetrics.width 属性,包含文本先前的宽度(行内盒子的宽度),使用 CSS 像素计算
可以看到,TextMetrics.width 取得的其实是 文本生成的行内盒子(inline box)的宽度。对于默认字体来说,可能看不太出,但当字体是由用户随意上传的时候这个问题就很明显了,比如我们点击加载字体 Hanab
可以看到下方的天蓝色 inline-box 里,字体的行宽高都是有明显溢出的,这个时候我们拿到的 ctx.measureText(str).width 就仅仅是 天蓝色 inline-box 的宽度,而不是字体的实际宽度;
因此我们实际需要的属性是 TextMetrics.actualBoundingBoxLeft 和TextMetrics.actualBoundingBoxRight ,它们获取到的值是
从
CanvasRenderingContext2D.textAlign属性确定的对齐点到文本矩形边界左/右侧的距离
即,假如我们设置了 textAlign: center , 这两个属性获取的是文本基于 center 这个点的左右两侧的距离
因此,字体的实际宽度是 ctx.measureText(str).actualBoundingBoxLeft + ctx.measureText(str).actualBoundingBoxRight 。当然也有可能出现字体实际宽度小于行内框的情况,可以自行找字体测试一下
需要注意的是,文字实际宽度 并不是 基于 center 定位点均分的
相关规范可以看这里
获取文本高度
有好好学过 css 的同学肯定看过这张图,行高由两个半边距加文字高度组成。原出处我找不着在哪了,知道的同学可以告知一下
我们可以看到,一般情况下,font-size 其实就是文本内容的实际高度。
然而🦢!看👀我们刚刚 Hanab 的例子,高度明显已经溢出行框了,因此 font-size 等于文本实际高度对于部分字体来说并不适用。
而在 canvas 中也有一张文本基线图,来自规范文档

可以看到,文本的实际高度等于 top of bounding box 与 bottom of bounding box,与之对应的 api 则是 TextMetrics 对象的另外两个属性 TextMetrics.fontBoundingBoxAscent 和 TextMetrics.fontBoundingBoxDescent 它们分别表示
从
CanvasRenderingContext2D.textBaseline属性标明的水平线到渲染文本的所有字体的矩形边界最顶/底部的距离,使用 CSS 像素计算
当我们设置 ctx.textBaseline = 'middle' ,则 fontBoundingBoxAscent 等于 middle 基线到 top of bounding box 的距离,fontBoundingBoxDescent 等于 middle 基线到 bottom of bounding box 的距离。
因此,文本的实际高度等于 ctx.measureText(str).actualBoundingBoxAscent + ctx.measureText(str).actualBoundingBoxDescent
需要注意的是,文字实际高度 并不是 基于 middle 基线均分的
二分法计算字体大小
搞清楚文本的实际宽高之后我们就要来计算合适的字体大小了。我们可以根据文本的实际宽高通过 for 循环,一点点缩小字体大小。但是这样效率会有点低,可以用 二分法 加快一下循环的效率。为了每次拿到的都是最新的 文本尺寸,我们第三个参数传入的是一个计算函数。
// 获取输入文本的实际高度
const getActualHeight = () => {
const { actualBoundingBoxAscent, actualBoundingBoxDescent } = ctx.measureText(str);
return actualBoundingBoxAscent + actualBoundingBoxDescent;
};
// 获取输入文本的实际宽度
const getActualWidth = () => {
const { actualBoundingBoxLeft, actualBoundingBoxRight } = ctx.measureText(str);
return actualBoundingBoxLeft + actualBoundingBoxRight;
};
/**
* @description: 二分法计算合适的字体尺寸
* @param {number} size 初始字体大小
* @param {number} max 预览图最大宽 / 高
* @param {function} culcalate 获取当前文本实际 宽 / 高 的计算函数
*/
const dichotomizeCulcalate = (size, max, culcalate) => {
let preResult;
let minSize = size;
let maxSize = size;
do {
preResult = culcalate();
if (preResult > max) {
maxSize = minSize;
minSize = Math.floor(minSize / 2);
} else {
minSize = Math.floor((maxSize - minSize) / 2) + minSize;
}
// 判断完当前尺寸后更新文本信息,进入下一次循环
ctx.font = `normal ${minSize}px ${font}`;
if (maxSize === minSize) break;
} while (preResult !== culcalate());
};
// 限制文字宽度
dichotomizeCulcalate(initFontSize, width, getActualWidth);
// 限制文字高度
dichotomizeCulcalate(Number(ctx.font.split('px').shift()), height, getActualHeight);
这里需要注意,限制高度时传入的初始字体大小是限制完宽度后,当前文本的字体大小。
绘制文本
上面的那些操作其实都还只是在内存中进行,我们并没有实际绘制到 canvas 上,现在我们才来执行绘制。
ctx.fillText() 需要传入三个参数,第一个参数是需要绘制的文本,第二三个参数是文本要在 canvas 中绘制的起始坐标;有使用过 canvas 的同学知道,canvas 的文字垂直水平居中是基于文本起始绘制点的,因此可能以为这里的坐标应该是画布的一半
ctx.fillText(
str,
width / 2,
height / 2
);
然而,我们上面有提到 文本实际并不是由 center 或 middle 均分的,因此,当我们设置了
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
又将 center 和 middle 对齐到画布正中间时,绘制的文本就可能会有部分内容在画布之外了,如下图,文本内容向右偏移了,有一部分在画布之外
我们可以在绘制的时候修正一下位置
ctx.fillText(
str,
(width - getActualWidth()) / 2 + ctx.measureText(str).actualBoundingBoxLeft, // 修正部分字体不由水平中线(center)均分导致的位移
(height - getActualHeight()) / 2 + ctx.measureText(str).actualBoundingBoxAscent, // 修正部分字体不由垂直中线(middle)均分导致的位移
);
修正后效果如下图
处理高倍屏
在上面的例子中,我们可以看到 canvas 中的文字是有些模糊的,这是因为我使用的屏幕是高倍屏。有好好学习 css 的同学一定知道 像素(pixel) 是分 物理像素(physical pixel) 和 css 像素 的。我们平时写在 css 里的px是css 像素,而实际上渲染的时候 屏幕可能会使用 多个物理像素 来表示一个 css 像素,使得画面效果更细腻。这个 物理像素 比 css 像素的比值就是 设备像素比 (devicePixelRatio)
physicalPixel / cssPixel = devicePixelRatio
通过 window.devicePixelRatio 可以查看
而 canvas 绘制到缓存区时也有一个比值 webkitBackingStorePixelRatio 这个值通常是 1 (下面也以 webkitBackingStorePixelRatio 是 1 为前提)。这个属性已废弃,我们了解下即可
也就是说我们在设置 canvas style宽高 为 400css像素 * 200css像素 且 webkitBackingStorePixelRatio 为1时,储存在缓存区的图像大小为 400物理像素 * 200物理像素;
假设我们的 devicePixelRatio 是 2,则屏幕上 canvas 画布的尺寸实际是 800物理像素 * 400物理像素
为了让缓存区 400物理像素 * 200物理像素 的图像能显示在屏幕上,需要将缓存区的图像放大两倍,即原本应该是 1个物理像素表现的内容,现在用两个物理像素表现了,这就导致了图像的 最小颗粒 变大了,导致画面变模糊 (相当于把小图拉大)
我们可以通过 scale 缩放解决这个问题,我们将 画布以devicePixelRatio 的比例放大,通过这种方式缩放不会导致画面锯齿。可以理解为这样在缓存区缓存的图像大小也就是 800物理像素 * 400物理像素,再把它绘制在 400css像素 * 200css像素 的画布上尺寸就是刚刚好的了
如果这里我把你讲懵逼了,可以直接看这篇文章 😭
导出图片
这一步也很简单
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve({
previewBlobUrl: createObjectURL(blob), // 预览图本地链接
previewBlob: blob,
});
} else {
reject(new Error('预览图生成失败'));
}
});
});
通过 canvas.toBlob 获取预览图的 blob,再通过 createObjectURL(blob) 创建预览图的本地访问链接
前面我们为了演示,是通过页面上的 canvas 元素生成的预览图
const canvas = document.getElementById('demo-canvas');
其实可以直接通过 createElement 动态生成 canvas 元素,在内存中无感生成预览图
const canvas = document.createElement('canvas');
最后,文中如有错漏,欢迎留言指正