动态文本绘制到指定尺寸Canvas,字体大小自适应

3,504 阅读8分钟

动态文本绘制到指定尺寸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,即普通的行内盒子

demo

字体加载

这一步比较简单,可以通过浏览器提供的 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

可以看到下方的天蓝色 inline-box 里,字体的行宽高都是有明显溢出的,这个时候我们拿到的 ctx.measureText(str).width 就仅仅是 天蓝色 inline-box 的宽度,而不是字体的实际宽度;

因此我们实际需要的属性是 TextMetrics.actualBoundingBoxLeftTextMetrics.actualBoundingBoxRight ,它们获取到的值是

CanvasRenderingContext2D.textAlign 属性确定的对齐点到文本矩形边界左/右侧的距离

即,假如我们设置了 textAlign: center , 这两个属性获取的是文本基于 center 这个点的左右两侧的距离

left-right

因此,字体的实际宽度是 ctx.measureText(str).actualBoundingBoxLeft + ctx.measureText(str).actualBoundingBoxRight 。当然也有可能出现字体实际宽度小于行内框的情况,可以自行找字体测试一下

需要注意的是,文字实际宽度 并不是 基于 center 定位点均分的

相关规范可以看这里

获取文本高度

有好好学过 css 的同学肯定看过这张图,行高由两个半边距加文字高度组成。原出处我找不着在哪了,知道的同学可以告知一下

line-height

我们可以看到,一般情况下,font-size 其实就是文本内容的实际高度。

然而🦢!看👀我们刚刚 Hanab 的例子,高度明显已经溢出行框了,因此 font-size 等于文本实际高度对于部分字体来说并不适用。

而在 canvas 中也有一张文本基线图,来自规范文档

图源 whatwg

可以看到,文本的实际高度等于 top of bounding boxbottom of bounding box,与之对应的 api 则是 TextMetrics 对象的另外两个属性 TextMetrics.fontBoundingBoxAscentTextMetrics.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
);

然而,我们上面有提到 文本实际并不是由 centermiddle 均分的,因此,当我们设置了

ctx.textAlign = 'center';
ctx.textBaseline = 'middle';

又将 center 和 middle 对齐到画布正中间时,绘制的文本就可能会有部分内容在画布之外了,如下图,文本内容向右偏移了,有一部分在画布之外

textalign

我们可以在绘制的时候修正一下位置

ctx.fillText(
  str,
  (width - getActualWidth()) / 2 + ctx.measureText(str).actualBoundingBoxLeft, // 修正部分字体不由水平中线(center)均分导致的位移
  (height - getActualHeight()) / 2 + ctx.measureText(str).actualBoundingBoxAscent, // 修正部分字体不由垂直中线(middle)均分导致的位移
);

修正后效果如下图

fix

处理高倍屏

在上面的例子中,我们可以看到 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');

最后,文中如有错漏,欢迎留言指正

参考

FontFace()

TextMetrics

dom-context-2d-textalign-center

高清屏中 Canvas 的绘制