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(源分离规则)划定:
- 若某字在任一来源标准(中国 GB、台湾 CNS、日本 JIS、韩国 KS)里本就被当作两个分开的字编码, Unicode 也保持分开——所以「戶 / 戸」「樣 / 様」「黑 / 黒」是不同码点,不在本页讨论之列。
- 否则视为同一个抽象字,合并成一个码点;地区书写习惯造成的字形差异(点的位置、部件朝向、笔画连断) 不体现在码点上,而是交给字体去画。
于是 U+5203 在「面向中文的字体」和「面向日文的字体」里被画成不同的「刃」——两种都对,只是地区习惯不同。
换句话说:code point 只确定「这是哪个抽象字」,不确定「长什么样」。后者由字体决定。
同一个码点,四语并排
输入一个汉字(或 U+5203 这样的码点),下面四个面板渲染的是同一个 code point,
只是分别声明了 lang="zh-Hans / zh-Hant / ja / ko" 并配上各地区字体。
字节完全一样,字形却可能不同——差别纯粹来自字体:
zh-Hans / ja 等对应地区的字体,
才能按 lang 选出不同字形。macOS 自带 PingFang SC / TC(中)、Hiragino Sans(日)、
Apple SD Gothic Neo(韩),差异通常一眼可见;若某语言字体缺失,该面板会回退成已有字体、看起来和邻格一样。
分歧字画廊:常被引用的几个例子
把一批经典分歧字横排展开,每行是同一个码点在四种 lang 下的字形。
扫一遍就能体会:这不是个别字的特例,而是系统性的地区差异:
| 码点 | zh-Hans 简体 |
zh-Hant 繁体 |
ja 日文 |
ko 韩文 |
差异点 |
|---|
字形由谁决定:字体 + lang
既然码点不带字形,浏览器是怎么决定画哪一种的?两条路:
- 字体回退看
lang—— 本页正文用的是不含 CJK 字形的西文字体,遇到汉字时浏览器要回退到系统 CJK 字体。 回退时会参考元素的lang选「该语言的」那套:lang="ja"优先 Hiragino、lang="zh-Hans"优先 PingFang SC。 - 一套字体内部按
locl切换 —— 支持多语言的泛 CJK 字体(Source Han Sans / Noto Sans CJK) 用 OpenType 的locl(localized forms)特性,按lang为同一个码点切换字形变体—— 一套字体就能画出中 / 日 / 韩三态。
lang,在中文环境(或中文字体)下显示日文文本,汉字会被画成中文字形——
对日本读者就是「字写错了」,尽管码点完全正确。本页这一整页 lang="zh-CN",所以正文里没标 lang 的汉字默认走中文字形;
上面两个 lab 之所以能显出日 / 韩字形,靠的就是逐元素标了 lang。
反过来:不同码点,却是同一个词 —— 繁简与异体字
Han 统一是「同码点 / 不同字形」。它有个天然的反面:謝(U+8B1D)和 谢(U+8C22)
是两个不同的码点,却是同一个词的繁简两种写法。这次连归一化都不会合并它们——四种 NF 跑下来,謝 仍是 謝。
那「它俩是一个意思」的信息存在哪?
为什么归一化故意不管这件事?因为繁简关系不满足「等价」的前提——它有损、且依赖词义,常常一对多:
发一个简体 ↔發(发展)/髮(头发)两个繁体;后↔后(皇后)/後(後面);干↔干/乾/幹。
若让 NFKC 把 谢 折成某个繁体,就会在别处把 发→髮 折错。所以这是查词典级的转换,不是字符级的无歧义映射,
Unicode 把它排除在归一化之外。真正记录这层关系的,是 Unihan 数据库(随 Unicode 一起发布的汉字属性库)里的异体字字段:
kSimplifiedVariant——謝记着「我的简体是U+8C22」;kTraditionalVariant——谢记着「我的繁体是U+8B1D」;- 还有
kSemanticVariant(同义异体)、kZVariant(字形变体)、kSpoofingVariant(易混)等。
实际转换用建立在它之上、再加词组级词典的库:OpenCC(繁简 + 地区用词)、ICU 的
Transliterator.getInstance("Traditional-Simplified")。注意 JS 原生不做繁简:
\p{…} / Intl / String.normalize 都不碰,必须引库。
把库跑起来:OpenCC 的词组级转换
上面那张手写的 S2T 表是单字级的,它给 发 列出 發 / 髮 两个候选,却选不出该用哪个——
字符级映射看不到上下文。真实转换走的是另一条路:OpenCC
用词组级词典做最大匹配,先认出「头发」「发展」这样的词,再整词替换,于是同一个 发 在不同词里转成不同的繁体字。
下面这个 lab 直接 import * as OpenCC from 'opencc-js'(纯前端、同步转换),把转换结果逐字与原文对齐:
改动的字标底色,同一个源字在本段里转出多种结果(被上下文消歧的一对多)再叠一层高亮:
发 并列 發 / 髮 但停在这——选词得靠人;OpenCC 凭「头发 / 发展」这层词的信息替你选了。
所以繁简转换不是字符级的无歧义映射,而是查词典级的处理,这正是 Unicode 把它排除在归一化之外的原因。
twp / 含「用词」的方向还会进一步替换地区惯用词(软件↔軟體、鼠标↔滑鼠、出租车↔計程車),
此时转换字数都可能变,更不可能用字符映射表完成。
其一 规范 / 兼容等价(归一化)—— 同一个抽象字的多种码点写法,拍平到一种(
Å 的两种写法);
其二 Han 统一(本页上半)—— 同一个码点的多种字形,交给字体选(
U+5203 的中 / 日 字形);
其三 异体字关系(本页这节)—— 不同码点表示同一个词,既不在码点也不在字形里,要查 Unihan(
謝 / 谢)。
一句话收束:看起来一样不等于码点一样,码点一样不等于看起来一样,码点不一样也不等于不是一个词。
其一 给内容标对
lang:整页 <html lang> 定基调,混排处用 <span lang="ja">…</span> 局部覆盖。
其二 字体栈按 locale 提供对应字体,或优先选支持
locl 的泛 CJK 字体(Source Han / Noto CJK),让同一套字体能按 lang 切字形。
其三 别指望从字符本身区分语言:
=== / 正则 / 索引都按 code point,一个「刃」是中文还是日文的,字符里没有这个信息——
要区分得另带语言元数据(如 lang 标注、来源标记)。