算法与数据结构 / regex · Unicode 字符模型与一个自研引擎 / 检测 emoji 支持:字体里有没有这个字形 待审核 14 / 36
渲染检测 · measureText / getImageData

检测 emoji 支持:字体里有没有这个字形

RGI 序列 那页判的是合法性:\p{RGI_Emoji} 说一串码点构成一个完整 emoji,这个答案在任何机器上都一样。渲染是另一回事——同一串码点在这台设备上画不画得出来,取决于本机 emoji 字体收没收这个字形,与系统版本、地区强绑定。常见做法是嗅探 User-Agent 猜厂商再假定其字体,但 UA 可随意伪装,也反映不出字体实际更新到了哪一代。可靠的办法是直接量渲染结果。

1 · 豆腐块基线

一个码点没有字形时,会回退到末位字体的 .notdef 字形,也就是那个空心方块「豆腐块」。关键在于:所有缺字形的码点都回退到同一个 .notdef,渲染出来的像素完全一致。于是判断某个字符有没有专属字形,只需看它画出来是否等于豆腐块。

必须比像素而非比宽度。某些系统的 .notdef 步进宽度恰好等于 emoji 宽度,只比宽度会把明明画得出来的字符(例如黑白呈现的 🥷)误判成缺字形。宽度这条线留给下一节的连字塌缩,那里比的是同一串文本的相对宽度,不受此坑影响。

图 1-1 · 两个未分配码点、一个黑白 emoji 与一个彩色 emoji 的实时渲染读数:着墨点数、宽度,以及是否与豆腐块像素一致。

2 · 三种检查手段

没有单一指标能覆盖所有 emoji:单码点、ZWJ 序列、国旗各有各的「未支持」表现。三种手段各对准一类。

表 2-1 · 三种渲染检查的适用范围、判据与已知坑。
检查 适用 判据
A · 字形存在 单码点,含加 VS16 的形态 目标与豆腐块的渲染像素不一致,即有专属字形 只要求有独立字形,不要求彩色
B · 连字塌缩 ZWJ、肤色、国旗等序列,tag 序列除外 整串宽度小于逐字符宽度之和,说明字体把它塌成了一个字形 国旗缺字形时退成字母方块,宽度同样塌窄
C · 彩色像素 国旗与 tag 子地区旗 彩色像素占着墨像素的比例超过阈值,说明真画出了旗 需要读像素,受 canvas 指纹保护干扰

警示 · 国旗必须靠 C 兜底。以 TW 为例,中国大陆地区的系统不画旗子,而是退成一个方块、里面两个字母:这个方块本身也是一个字形,宽度也塌窄了,B 因此会判成支持。真旗与字母方块的差别在颜色——真旗有大片彩色,字母方块是灰度。图 3-1 里只留 B 一项检查时,TW 的结论就会翻成支持。

3 · 综合判定

三种检查的结论按「不支持优先」合并:在勾选启用且适用于当前输入的检查里,只要有一个判否,综合结论就是不支持;全判是才是支持;若勾选的检查都不适用,则无法判定。本页实际跑的就是下面这段。

const TOFU = pixels('͸');   // 豆腐块基线: { ink, colored, hash }
const isRI = (cp) => cp >= 0x1F1E6 && cp <= 0x1F1FF;
const isTag = (cp) => cp >= 0xE0020 && cp <= 0xE007F;
const isVS = (cp) => cp === 0xFE0F || cp === 0xFE0E;

function checks(str) {
  const cps = [...str].map((c) => c.codePointAt(0));
  const core = cps.filter((cp) => !isVS(cp));
  const w = measure(str);                        // 整串宽度
  const parts = cps.filter((cp) => !isVS(cp))    // 拆开逐个量再相加
    .reduce((n, cp) => n + measure(String.fromCodePoint(cp)), 0);
  const px = pixels(str);
  const ratio = px.ink ? px.colored / px.ink : 0;
  return {
    glyph:    { on: core.length <= 1,                     pass: px.ink > 0 && px.hash !== TOFU.hash },
    collapse: { on: core.length >= 2 && !cps.some(isTag), pass: w + 1 < parts },
    color:    { on: (core.length >= 2 && core.every(isRI)) || cps.some(isTag), pass: ratio > 0.05 },
  };
}

function supported(str, use) {
  const c = checks(str);
  const active = ['glyph', 'collapse', 'color'].filter((k) => use[k] && c[k].on);
  if (!active.length) return null;              // 没有适用的检查
  return active.every((k) => c[k].pass);        // 不支持优先
}
图 3-1 · 任意 emoji 的三种检查逐条实测与综合结论。可勾选启用哪几种,取消 C 可复现国旗的误判。

4 · 对照组与版本阶梯

两组都跑三种检查全开的综合判定,但性质不同。对照组不依赖设备新旧:未分配码点渲染成豆腐块、无效地区码退成字母方块,在任何机器上都判不支持,总能演示三种检查的分工。版本阶梯则是对本机的实时探测,按 emoji 版本从旧到新排;从某一版起开始出现否定结论,那一版就是这台设备 emoji 字体的更新截止线。

图 4-1 · 上半为对照组,下半为 Emoji 1.0 到 17.0 的版本阶梯,逐个给出本机的综合判定。

注 · 三种检查里只有 B 纯用 measureText,跨机稳定;A 与 C 要 getImageData 读像素,因而会被浏览器的 canvas 指纹保护掺入噪声,结论可能不稳(Firefox 的 privacy.resistFingerprinting、Brave 的 farbling 均属此列,核对于 2026-08)。那种环境下 A 可退回只比宽度,但要接受 .notdef 宽度与 emoji 撞车带来的误判。

5 · 参考文献

  1. Microsoft. Recommendations for OpenType Fonts. 字形 0 即 .notdef 的约定:缺字形时渲染引擎回退到它,本页 A 检查的基线由此而来。learn.microsoft.com
  2. MDN. CanvasRenderingContext2D.measureText(). 宽度测量接口,B 检查的全部依据;返回的 TextMetrics 各项含义另见 TextMetricsdeveloper.mozilla.org
  3. MDN. CanvasRenderingContext2D.getImageData(). 像素读取接口,A 与 C 检查靠它。developer.mozilla.org
  4. MDN. HTMLCanvasElement.getContext(). willReadFrequently 选项在此定义,连续读像素时用它避开回读惩罚。developer.mozilla.org
  5. Unicode. emoji-test.txt(Emoji 17.0). 每个 emoji 的版本标记(E13.0 等),版本阶梯的年代划分照它。unicode.org
  6. Unicode. Full Emoji List. 各厂商字体的渲染对照表,用来核对某个 emoji 在某平台上到底画成什么样。unicode.org