Opencc4j

Opencc4j 支持中文繁简体转换,考虑到词组级别。

开源中文的繁简体转换 opencc4j-01-使用入门概览

开源中文的繁简体转换 opencc4j-02-一个汉字竟然对应两个 char?

开源中文的繁简体转换 opencc4j-03-简体还是繁体,你说了算!

开源中文的繁简体转换 opencc4j-04-香港繁简体的支持

开源中文的繁简体转换 opencc4j-05-日文转换支持

Features 特点

  • 严格区分「一简对多繁」和「一简对多异」。

  • 完全兼容异体字,可以实现动态替换。

  • 严格审校一简对多繁词条,原则为「能分则不合」。

  • 词库和函数库完全分离,可以自由修改、导入、扩展。

  • 兼容 Windows、Linux、Mac 平台。

  • 支持自定义分词

  • 支持判断单个字(词)是否为简体/繁体

  • 支持返回字符串中简体/繁体的列表信息

  • 支持中国台湾、香港地区繁简体转换

  • 支持与日文字的转换

从一个 bug 说起

很久很久以前,收到了一个用户的 issue 部分生僻字转小写之后会得到一个乱码(不可见字符)

内容如下:

  [plaintext]
1
2
3
例如“嘪球”在转换之后得到“𪡃球”,还有其他一些字也存在这个问题。查阅源码发现是 TSCharacters.txt 文件中定义的 “嘪 𪡃”导致,是否可以将这类会产生乱码的字用转化之前的字本身来代替,作为兜底策略,防止得到乱码。 只需要把该文件中,映射后为乱码的kv对替换掉即可,例如“嘪 𪡃”替换成“嘪 嘪”。

第一个感觉是,这合理吗?

也就没太放在心上,甚至怀疑是不是用户缺少对应的字库?

无独有偶,回头又看了另一个非常类似的问题。

关于部分异体字实际占用两个字符的情况

描述如下:

  [plaintext]
1
2
3
在实际使用库转换一些古籍文本时,有不少的文字转换失败,实际调试发现,有些异体字如𨦟,其占用两个char作为一个完整意义上的可见字符,而库中源码将字符串转为字符串数组的方式可能会将这种关联断掉,导致转换失败。 实际自己的魔改实践发现,java.lang.String#codePointCount方法可以得到一个字符串中所含有的完整【字符】数量,例图二,我想请问您是否有打算兼容这种情况。

pic1

pic2

一个汉字对应两个 char,当年的自己还是想的太简单了。

汉字编码真奇妙

一般的汉字对应一个 Unicode char,但是有时候会有例外。

直接 string.toCharArray() 会导致拆分错误。

那么,一切就要从最基本的编码知识还是说起,感觉枯燥的小伙伴可以直接跳过。

一、Unicode 编码基础

1. 码点(Code Point)

  • Unicode为每个字符分配唯一的数字标识(码点),格式为 U+XXXX(如 U+4E00 表示汉字”一”)。
  • 范围:U+0000U+10FFFF(共约 111万 个码位)。

2. 平面(Plane)

  • Unicode将码点空间划分为 17个平面,每个平面包含 65,536(0x00000xFFFF)个码点。
  • 基本多文种平面(BMP, Plane 0):U+0000U+FFFF,涵盖绝大多数常用字符(如拉丁字母、汉字基础部分)。
  • 补充平面(Supplementary Planes, Plane 1–16):U+10000U+10FFFF,包含较少使用的字符(如古汉字、emoji)。

二、汉字在 Unicode 中的分布

1. 基本汉字(BMP内)

  • 基本区:U+4E00U+9FFF
    • 包含 20,971 个常用汉字(如”中” U+4E2D)。
  • 扩展A区:U+3400U+4DBF
    • 包含 6,582 个汉字(如”𠀀” U+3400)。
  • 扩展B–G区:分布在补充平面中(见下文)。

2. 扩展汉字(补充平面)

  • 扩展B区:U+20000U+2A6DF(Plane 2)
    • 包含 42,711 个汉字(如”𠮷” U+20BB7)。
  • 扩展C–H区:如 U+2A700U+2B81F(Plane 2-3)
    • 涵盖古汉字、方言字等(如”𪜎” U+2A70E)。

三、UTF-16 编码与补充平面字符

1. UTF-16 编码规则

  • BMP字符(U+0000–U+FFFF):直接用一个16位单元(char)表示。
  • 补充平面字符(U+10000–U+10FFFF):使用 代理对(Surrogate Pair) 编码:
    1. 高位代理(High Surrogate):0xD8000xDBFF(前导代理)。
    2. 低位代理(Low Surrogate):0xDC000xDFFF(后随代理)。
  • 计算方式:
      [plaintext]
    1
    码点 = 0x10000 + ((高位代理 - 0xD800) << 10) + (低位代理 - 0xDC00)

2. 示例:汉字”𠮷”(U+20BB7)

  • 码点计算:
    1. 码点 0x20BB7 减去 0x100000x10BB7
    2. 高位代理 = 0xD800 + (0x10BB7 >> 10)0xD842
    3. 低位代理 = 0xDC00 + (0x10BB7 & 0x3FF)0xDFB7
  • UTF-16编码:0xD842 0xDFB7(Java中用两个char表示)。

四、处理补充平面字符的实践

1. Java中的关键方法

  • 获取码点:codePointAt(int index)
    • 自动处理代理对,返回完整码点。
  • 判断字符类型:
    • Character.isHighSurrogate(char)
    • Character.isLowSurrogate(char)
  • 码点转字符数:Character.charCount(int codePoint)
    • 返回 1(BMP)或 2(补充平面)。

2. 代码示例:遍历字符串中的完整字符

  [java]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static void printCodePoints(String s) { int length = s.length(); for (int i = 0; i < length; ) { int codePoint = s.codePointAt(i); int charCount = Character.charCount(codePoint); System.out.printf("字符: %s → 码点: U+%04X%n", s.substring(i, i + charCount), codePoint); i += charCount; } } // 输入: "A𠮷B" // 输出: // 字符: A → 码点: U+0041 // 字符: 𠮷 → 码点: U+20BB7 // 字符: B → 码点: U+0042

解决方案

chars 拆分

知道了问题所在,剩下的主要问题就是修正。

我们将以前粗暴的 toCharArray 修正一下,兼容特殊的异体字。

修正后的方法如下:

  [java]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static List<String> toCharList(String input) { if(StringUtil.isEmpty(input)) { return Collections.emptyList(); } List<String> characters = new ArrayList<>(); int length = input.length(); for (int i = 0; i < length; ) { char high = input.charAt(i); if (Character.isHighSurrogate(high) && i + 1 < length) { char low = input.charAt(i + 1); if (Character.isLowSurrogate(low)) { characters.add(new String(new char[]{high, low})); i += 2; continue; } } characters.add(Character.toString(high)); i += 1; } return characters; }

然后就是各种调整,将 char 全部改为 string 匹配处理。

中文的判断

以前对于中文的字符判断,格局也是小了。

  [java]
1
2
3
4
5
6
7
8
9
/** * 是否为中文 * @param ch 中文 * @return 是否 * @since 0.1.76 */ public static boolean isChinese(final char ch) { return ch >= 0x4E00 && ch <= 0x9FA5; }

这个是不够的,也要调整一下

  [java]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** * 兼容异体字 * @param s 字符 * @return 结果 */ public static boolean isChineseForSingle(final String s) { if (s == null || s.isEmpty() || s.length() > 2) { return false; // 非空且长度不超过2 } // 获取字符串的码点(支持代理对) int codePoint = s.codePointAt(0); // 检查字符串长度是否与码点所需字符数匹配 if (s.length() != Character.charCount(codePoint)) { return false; // 非法代理对或长度不匹配 } // 扩展中文判断范围(按需调整) return (codePoint >= 0x4E00 && codePoint <= 0x9FFF) || // 基本汉字 (codePoint >= 0x3400 && codePoint <= 0x4DBF) || // 扩展A (codePoint >= 0x20000 && codePoint <= 0x2A6DF); // 扩展B(示例) }

如此,经过一番调整以后,终于算是兼容了多字符的场景。

测试验证

V1.9.1 版本支持这种特性,测试例子如下

  [java]
1
2
3
4
5
6
String originText = "\uD862\uDD9F"; Assert.assertEquals(true, ZhConverterUtil.isChinese(originText)); // 此处兼容缺失的映射,返回本身 String text = "\uD86A\uDC43还有\uD862\uDD9F"; Assert.assertEquals("\uD86A\uDC43还有\uD862\uDD9F", ZhConverterUtil.toSimple(text)); Assert.assertEquals("\uD86A\uDC43還有\uD862\uDD9F", ZhConverterUtil.toTraditional(text));

小结

还是要保持空杯的心态,不要想当然。

就连我们日常使用的汉字,随手的转字符数组,可能都会存在问题。

我是老马,期待与你的下次重逢。

拓展阅读

pinyin 汉字转拼音

pinyin2hanzi 拼音转汉字

segment 高性能中文分词

opencc4j 中文繁简体转换

nlp-hanzi-similar 汉字相似度

word-checker 拼写检测

sensitive-word 敏感词