regex 总览待审核
字符的真面目 · 同码点多字形

Han 统一:同一个码点,中日韩为什么显示成不同的字

归一化那页处理的是「不同 code point 写出同一个字」(Å 的两种写法)。 本页正相反:U+5203(刃)只有一个码点,却在中文 / 日文 / 韩文字体里画成不同字形。 这就是 Han Unification(汉字统一)——Unicode 把中日韩历史同源的表意文字合并到同一批码点, 字形差异交给字体 + lang 处理。结论先行:===、正则、索引都只认 code point,看不见字形; 不给文本标对 lang,就会拿中文字形去显示日文——码点没错,字却「写错了」。

根源:为什么同一个码点要给好几种字形

中日韩(以及越南喃字)共用大量同源汉字。如果按语言各编一套,光汉字就要翻几倍,码空间会爆炸、彼此还不兼容。 Unicode 的选择是合并:把「同一个抽象字」在各地的写法收到同一个 code point 下,这批字就是 CJK Unified Ideographs(U+4E00 起,见 \p{Script=Han})。

合并到什么边界,由 Source Separation Rule(源分离规则)划定:

于是 U+5203 在「面向中文的字体」和「面向日文的字体」里被画成不同的「刃」——两种都对,只是地区习惯不同。 换句话说:code point 只确定「这是哪个抽象字」,不确定「长什么样」。后者由字体决定。

同一个码点,四语并排

输入一个汉字(或 U+5203 这样的码点),下面四个面板渲染的是同一个 code point, 只是分别声明了 lang="zh-Hans / zh-Hant / ja / ko" 并配上各地区字体。 字节完全一样,字形却可能不同——差别纯粹来自字体:

看不出差别?这一步依赖你系统里装了哪些 CJK 字体:浏览器要先有 zh-Hans / ja 等对应地区的字体, 才能按 lang 选出不同字形。macOS 自带 PingFang SC / TC(中)、Hiragino Sans(日)、 Apple SD Gothic Neo(韩),差异通常一眼可见;若某语言字体缺失,该面板会回退成已有字体、看起来和邻格一样。

分歧字画廊:常被引用的几个例子

把一批经典分歧字横排展开,每行是同一个码点在四种 lang 下的字形。 扫一遍就能体会:这不是个别字的特例,而是系统性的地区差异:

码点 zh-Hans
简体
zh-Hant
繁体
ja
日文
ko
韩文
差异点

字形由谁决定:字体 + lang

既然码点不带字形,浏览器是怎么决定画哪一种的?两条路:

这正是「Your Code Displays Japanese Wrong」一文的要害: 网页 / 应用若不标 lang,在中文环境(或中文字体)下显示日文文本,汉字会被画成中文字形—— 对日本读者就是「字写错了」,尽管码点完全正确。本页这一整页 lang="zh-CN",所以正文里没标 lang 的汉字默认走中文字形; 上面两个 lab 之所以能显出日 / 韩字形,靠的就是逐元素标了 lang

反过来:不同码点,却是同一个词 —— 繁简与异体字

Han 统一是「同码点 / 不同字形」。它有个天然的反面:(U+8B1D)和 (U+8C22) 是两个不同的码点,却是同一个词的繁简两种写法。这次连归一化都不会合并它们——四种 NF 跑下来, 仍是 。 那「它俩是一个意思」的信息存在哪?

为什么归一化故意不管这件事?因为繁简关系不满足「等价」的前提——它有损、且依赖词义,常常一对多:

若让 NFKC 把 折成某个繁体,就会在别处把 发→髮 折错。所以这是查词典级的转换,不是字符级的无歧义映射, Unicode 把它排除在归一化之外。真正记录这层关系的,是 Unihan 数据库(随 Unicode 一起发布的汉字属性库)里的异体字字段:

实际转换用建立在它之上、再加词组级词典的库:OpenCC(繁简 + 地区用词)、ICUTransliterator.getInstance("Traditional-Simplified")。注意 JS 原生不做繁简: \p{…} / Intl / String.normalize 都不碰,必须引库。

把库跑起来:OpenCC 的词组级转换

上面那张手写的 S2T 表是单字级的,它给 列出 發 / 髮 两个候选,却选不出该用哪个—— 字符级映射看不到上下文。真实转换走的是另一条路:OpenCC词组级词典最大匹配,先认出「头发」「发展」这样的,再整词替换,于是同一个 在不同词里转成不同的繁体字。 下面这个 lab 直接 import * as OpenCC from 'opencc-js'(纯前端、同步转换),把转换结果逐字与原文对齐: 改动的字标底色,同一个源字在本段里转出多种结果(被上下文消歧的一对多)再叠一层高亮:

OpenCC 输出
对照上面的手写表看:手写表给 并列 發 / 髮 但停在这——选词得靠人;OpenCC 凭「头发 / 发展」这层的信息替你选了。 所以繁简转换不是字符级的无歧义映射,而是查词典级的处理,这正是 Unicode 把它排除在归一化之外的原因。 twp / 含「用词」的方向还会进一步替换地区惯用词(软件↔軟體鼠标↔滑鼠出租车↔計程車), 此时转换字数都可能变,更不可能用字符映射表完成。 三层别混,这是两页的合拢:Unicode 里「抽象字」「码点」「字形」是三层,对应三种不同的「相同」:
其一 规范 / 兼容等价(归一化)—— 同一个抽象字的多种码点写法,拍平到一种(Å 的两种写法);
其二 Han 统一(本页上半)—— 同一个码点的多种字形,交给字体选(U+5203 的中 / 日 字形);
其三 异体字关系(本页这节)—— 不同码点表示同一个词,既不在码点也不在字形里,要查 Unihan(謝 / 谢)。
一句话收束:看起来一样不等于码点一样,码点一样不等于看起来一样,码点不一样也不等于不是一个词。
实战建议:
其一 给内容标对 lang:整页 <html lang> 定基调,混排处用 <span lang="ja">…</span> 局部覆盖。
其二 字体栈按 locale 提供对应字体,或优先选支持 locl 的泛 CJK 字体(Source Han / Noto CJK),让同一套字体能按 lang 切字形。
其三 别指望从字符本身区分语言:=== / 正则 / 索引都按 code point,一个「刃」是中文还是日文的,字符里没有这个信息—— 要区分得另带语言元数据(如 lang 标注、来源标记)。