检测自定义字体是否加载

2022-04-18 20:52:222022-04-18 21:32:33

我们可以通过加载自定义字体来实现字体反爬虫,那么如何检测字体是否正常加载呢?

有几个维度的检测:

  1. 检测字体是否加载成功

  2. 检测内容是否正常渲染

检测字体是否加载

最简单的方法是使用浏览器提供的FontFaceSet API来检测,其中要用到的是FontFaceSet.check()

FontFaceSet 的check()方法会返回是否在给定的字体列表中的所有字体已经被加载并可用。--MDN

示例:

document.fonts.check("12px courier");

document.fonts.check("12px MyFont""ß"); // 如果字体“MyFont”具有ß字符,则返回true。

但这个检测较为简陋,无法检测字体错误的情况(字体正常加载,但不是我们想要的,页面上显示乱码)

检测内容是否正常渲染

针对这种情况,我们可以使用一些比较骚的操作:

  1. 创建一个span标签

  2. 给标签添加特定字符

  3. 重置字体样式

  4. 分别测量在添font-family前后标签大小

  5. 大小一致 ? 字体加载失败 : 字体加载成功

(这里要创建span标签,如果是div的话宽度默认占满,无法测量真实的大小)

使用代码表示就是:

/**
 * 检测字体是否正常渲染
 * @param {string} font 字体名称
 * @param {Array<string>} phrase 待匹配的字符
 * @return {boolean} 是否存在该字体
 */
export function checkFont(font, phrase) {

  /**
   * 可以使用上述的 FontFaceSet API 作简要的判断,如果字体都不存在,那么肯定渲染失败
   */
  if (document.fonts) {
    const fonts = [...document.fonts];
    if (fonts.findIndex((f) => f.family === font) === -1) {
      console.log('font dose not exist');
      return false;
    }
  }

  // 创建一个空的 span 标签来填充内容
  let node = document.createElement("span");

  // 因为可以同时检测多个字符,因此需要存储一下各个字符的大小
  const sizes = [];

  /**
   * 计算单个字符的尺寸
   * @param {string} char 待计算的字符
   */
  function checkChar(char) {
    // 重置字体
    node.style.fontFamily = "sans-serif";
    
    // 填充内容
    node.innerHTML = char;

    const size = {};

    // 先保存未设置字体时的尺寸
    size.withoutFont = {
      width: node.offsetWidth,
      height: node.offsetHeight,
    };

    // 设置待检测的字体
    node.style.fontFamily = font + ", sans-serif";

    // 保存设置字体后的尺寸
    size.withFont = {
      width: node.offsetWidth,
      height: node.offsetHeight,
    };

    // 保存该字符设置字体/未设置字体时的尺寸,待字符计算完毕后统一计算
    sizes.push(size);
  }

  // 隐藏该 span 标签
  node.style.position = "absolute";
  node.style.left = "-10000px";
  node.style.top = "-10000px";

  // 字体大小设置大些误差小(offsetWidth和offsetHeight会四舍五入)
  node.style.fontSize = "300px";

  // 重置字体样式
  node.style.fontFamily = "sans-serif";
  node.style.fontVariant = "normal";
  node.style.fontStyle = "normal";
  node.style.fontWeight = "normal";
  node.style.letterSpacing = "0";

  // 插入页面中才会有 offsetWidth 和 offsetHeight
  document.body.appendChild(node);

  // 计算每一个字符的尺寸
  phrase.forEach(checkChar);

  /**
   * 1. 当 offsetWidth 和 offsetHeight 均不相同时说明字体存在
   * 2. 有一个字符的尺寸相同就说明字体有误
   */
  const res =
    node &&
    sizes.reduce(
      (acc, cur) =>
        acc &&
        (cur.withFont.width !== cur.withoutFont.width ||
          cur.withFont.height !== cur.withoutFont.height),
      true
    );

  // 做一些善后工作
  node.parentNode.removeChild(node);
  node = null;

  return res;
}

指数退避算法检测内容是否正常渲染

由于网络字体的加载需要时间,我们可通过简单地设置一个延时调用上述的checkFont,但可能有更好的方式来实现:指数退避算法

(自己理解)简单来讲就是在不超过所设置的时间范围内,以指数级超市时间定期重试

示例:

  1. 向服务端发送请求

  2. 失败?等待1秒后重试

  3. 失败?等待2秒后重试

  4. 失败?等待4秒后重试

  5. 失败?等待8秒后重试

  6. 以此类推,直到达到超时时间

在检测字体是否正常加载时,我们也可以使用该算法来更好地检测

(也可使用FontFaceSet: loadingdone event来作为时间点检测)

const check = (phrase, options = { timeout: 8192 }) => {
  function doCheck(t) {
    let tid = setTimeout(() => {
      clearTimeout(tid);
      tid = null;
      const res = checkFont("m-font", phrase);
      if (res) {
        // success!
      }
      if (!res && (t << 1 < options.timeout)) {
        doCheck(t << 1);
      }
      if (!res && (t << 1) >= options.timeout) {
        // parse error!
      }
    }, t);
  }

  // 我们可以以64ms为起始时间点
  doCheck(2 << 5);
};

总结

  1. 可使用FontFaceSet API来做简单的字体是否加载判断,但无法检测字体是否正常渲染

  2. 不同字体下,大概率各个字符的大小是不一样的,可依次来检测字体是否正常渲染

结束!

参考

FontFaceSet API

How can I detect when a font is downloaded via CSS's "unicode-range" descriptor?

指数退避算法