检测 emoji 支持:字体里有没有这个字形
RGI 序列 那页判的是合法性:\p{RGI_Emoji} 说一串码点构成一个完整 emoji,这个答案在任何机器上都一样。渲染是另一回事——同一串码点在这台设备上画不画得出来,取决于本机 emoji 字体收没收这个字形,与系统版本、地区强绑定。常见做法是嗅探 User-Agent
猜厂商再假定其字体,但 UA 可随意伪装,也反映不出字体实际更新到了哪一代。可靠的办法是直接量渲染结果。
1 · 豆腐块基线
一个码点没有字形时,会回退到末位字体的 .notdef 字形,也就是那个空心方块「豆腐块」。关键在于:所有缺字形的码点都回退到同一个 .notdef,渲染出来的像素完全一致。于是判断某个字符有没有专属字形,只需看它画出来是否等于豆腐块。
必须比像素而非比宽度。某些系统的 .notdef 步进宽度恰好等于 emoji 宽度,只比宽度会把明明画得出来的字符(例如黑白呈现的 🥷)误判成缺字形。宽度这条线留给下一节的连字塌缩,那里比的是同一串文本的相对宽度,不受此坑影响。
2 · 三种检查手段
没有单一指标能覆盖所有 emoji:单码点、ZWJ 序列、国旗各有各的「未支持」表现。三种手段各对准一类。
| 检查 | 适用 | 判据 | 坑 |
|---|---|---|---|
| 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); // 不支持优先
}
4 · 对照组与版本阶梯
两组都跑三种检查全开的综合判定,但性质不同。对照组不依赖设备新旧:未分配码点渲染成豆腐块、无效地区码退成字母方块,在任何机器上都判不支持,总能演示三种检查的分工。版本阶梯则是对本机的实时探测,按 emoji 版本从旧到新排;从某一版起开始出现否定结论,那一版就是这台设备 emoji 字体的更新截止线。
注 · 三种检查里只有 B 纯用 measureText,跨机稳定;A 与 C 要 getImageData 读像素,因而会被浏览器的 canvas 指纹保护掺入噪声,结论可能不稳(Firefox 的 privacy.resistFingerprinting、Brave 的 farbling 均属此列,核对于 2026-08)。那种环境下 A
可退回只比宽度,但要接受 .notdef 宽度与 emoji 撞车带来的误判。
5 · 参考文献
- Microsoft. Recommendations for OpenType Fonts. 字形 0 即
.notdef的约定:缺字形时渲染引擎回退到它,本页 A 检查的基线由此而来。learn.microsoft.com -
MDN. CanvasRenderingContext2D.measureText(). 宽度测量接口,B 检查的全部依据;返回的
TextMetrics各项含义另见 TextMetrics。developer.mozilla.org - MDN. CanvasRenderingContext2D.getImageData(). 像素读取接口,A 与 C 检查靠它。developer.mozilla.org
- MDN. HTMLCanvasElement.getContext().
willReadFrequently选项在此定义,连续读像素时用它避开回读惩罚。developer.mozilla.org - Unicode. emoji-test.txt(Emoji 17.0). 每个 emoji 的版本标记(
E13.0等),版本阶梯的年代划分照它。unicode.org - Unicode. Full Emoji List. 各厂商字体的渲染对照表,用来核对某个 emoji 在某平台上到底画成什么样。unicode.org