1. 概述
许多字母表包含重音和变音符号。为了可靠地搜索或索引数据,我们可能需要将带有变音符号的字符串转换为仅包含ASCII字符的字符串。Unicode定义了文本规范化过程来帮助实现这一点。
本教程将介绍:
- 什么是Unicode文本规范化
- 如何用它去除变音符号
- 需要避免的坑
然后我们会通过Java的Normalizer
类和Apache Commons的StringUtils
展示具体示例。
2. 问题速览
假设我们需要处理包含以下变音符号的文本:
āăąēîïĩíĝġńñšŝśûůŷ
读完本文后,你将知道如何去除这些变音符号,得到:
aaaeiiiiggnnsssuuy
3. Unicode基础
在写代码前,先了解一些Unicode基础知识。
Unicode可以使用不同的码点序列来表示带重音或变音符号的字符。这是为了与旧字符集保持历史兼容性。
Unicode规范化就是使用标准定义的等价形式分解字符。
3.1 Unicode等价形式
为了比较码点序列,Unicode定义了两个概念:规范等价和兼容等价。
- 规范等价:码点在显示时外观和含义完全相同。例如字母"ś"(带锐音的拉丁字母s)可以用单一码点+U015B表示,也可以用两个码点+U0073(拉丁字母s)和+U0301(锐音符号)表示。
- 兼容等价:序列外观可能不同,但在某些上下文中含义相同。例如码点+U013F(拉丁连字"Ŀ")与序列+U004C(拉丁字母L)和+U00B7(符号"·")兼容。有些字体会将中间点显示在L内部,有些则显示在后面。
规范等价的序列一定是兼容的,但反之不一定成立。
3.2 字符分解
字符分解会将复合字符替换为基准字母的码点,后接组合字符(根据等价形式)。例如该过程会将字母"ā"分解为"a"和"-"。
3.3 匹配变音和重音符号
分离基准字符和变音符号后,我们需要创建匹配非必要字符的表达式。可以使用字符块或字符类别。
最常用的Unicode字符块是组合变音符号块(Combining Diacritical Marks)。它不大,仅包含112个最常见的组合字符。也可以使用Unicode的Mark类别,它包含所有组合标记码点,并细分为三个子类别:
- Nonspacing_Mark:包含1,839个码点
- Enclosing_Mark:包含13个码点
- Spacing_Combining_Mark:包含443个码点
字符块和类别的主要区别是:字符块包含连续的字符范围,而类别可能包含多个字符块。例如组合变音符号块的所有码点也都属于Nonspacing_Mark类别。
4. 算法
理解了基础概念后,我们可以规划从字符串中去除变音符号的算法:
- 使用
Normalizer
类分离基准字符和变音符号,执行兼容性分解(对应Java枚举NFKD
)。选择兼容性分解是因为它能分解更多连字(如"fi")。 - 使用正则表达式
\\p{M}
移除所有属于Unicode Mark类别的字符。选择这个类别是因为它覆盖范围最广。
5. 使用Java核心库
先看几个使用Java核心库的示例。
5.1 检查字符串是否已规范化
执行规范化前,可以先检查字符串是否已规范化:
assertFalse(Normalizer.isNormalized("āăąēîïĩíĝġńñšŝśûůŷ", Normalizer.Form.NFKD));
5.2 字符串分解
如果字符串未规范化,继续下一步。用兼容性分解分离ASCII字符和变音符号:
private static String normalize(String input) {
return input == null ? null : Normalizer.normalize(input, Normalizer.Form.NFKD);
}
此步骤后,"â"和"ä"都会被分解为"a"加上各自的变音符号。
5.3 移除变音和重音码点
分解字符串后,移除非必要码点。使用Unicode正则表达式\\p{M}
:
static String removeAccents(String input) {
return normalize(input).replaceAll("\\p{M}", "");
}
5.4 测试
来看实际效果。首先测试可分解的Unicode字符:
@Test
void givenStringWithDecomposableUnicodeCharacters_whenRemoveAccents_thenReturnASCIIString() {
assertEquals("aaaeiiiiggnnsssuuy", StringNormalizer.removeAccents("āăąēîïĩíĝġńñšŝśûůŷ"));
}
再测试没有分解映射的字符:
@Test
void givenStringWithNondecomposableUnicodeCharacters_whenRemoveAccents_thenReturnOriginalString() {
assertEquals("łđħœ", StringNormalizer.removeAccents("łđħœ"));
}
正如预期,这些字符无法分解。
还可以验证分解后字符的十六进制码点:
@Test
void givenStringWithDecomposableUnicodeCharacters_whenUnicodeValueOfNormalizedString_thenReturnUnicodeValue() {
assertEquals("\\u0066 \\u0069", StringNormalizer.unicodeValueOfNormalizedString("fi"));
assertEquals("\\u0061 \\u0304", StringNormalizer.unicodeValueOfNormalizedString("ā"));
assertEquals("\\u0069 \\u0308", StringNormalizer.unicodeValueOfNormalizedString("ï"));
assertEquals("\\u006e \\u0301", StringNormalizer.unicodeValueOfNormalizedString("ń"));
}
5.5 使用Collator比较带重音的字符串
java.text
包中的Collator
类也很有用,它能执行区域敏感的字符串比较。重要配置属性是Collator
的强度(strength),它定义了比较时被视为显著差异的最小级别。
Java为Collator提供四种强度值:
- PRIMARY:忽略大小写和重音的比较
- SECONDARY:忽略大小写但包含重音和变音符号的比较
- TERTIARY:包含大小写和重音的比较
- IDENTICAL:所有差异都显著
示例代码:
Collator collator = Collator.getInstance();
collator.setDecomposition(2);
collator.setStrength(0); // PRIMARY
assertEquals(0, collator.compare("a", "a"));
assertEquals(0, collator.compare("ä", "a")); // 忽略重音
assertEquals(0, collator.compare("A", "a")); // 忽略大小写
assertEquals(1, collator.compare("b", "a"));
collator.setStrength(1); // SECONDARY
assertEquals(1, collator.compare("ä", "a")); // 区分重音
assertEquals(0, collator.compare("A", "a")); // 仍忽略大小写
collator.setStrength(2); // TERTIARY
assertEquals(1, collator.compare("A", "a")); // 区分大小写
collator.setStrength(3); // IDENTICAL
assertEquals(-1, collator.compare(valueOf(toChars(0x0001)), valueOf(toChars(0x0002)))); // 区分控制字符
踩坑提醒:如果字符没有定义分解规则,即使基准字母相同也不会被视为相等。因为Collator
无法执行Unicode分解:
collator.setStrength(0);
assertEquals(1, collator.compare("ł", "l")); // 无法分解
assertEquals(1, collator.compare("ø", "o"));
6. 使用Apache Commons StringUtils
了解了核心Java方法后,看看Apache Commons Text提供了什么。它使用更简单,但对分解过程的控制更少。底层使用Normalizer.normalize()
方法和NFD
分解形式,以及\p{InCombiningDiacriticalMarks}
正则表达式:
static String removeAccentsWithApacheCommons(String input) {
return StringUtils.stripAccents(input);
}
6.1 测试
先测试可分解的Unicode字符:
@Test
void givenStringWithDecomposableUnicodeCharacters_whenRemoveAccentsWithApacheCommons_thenReturnASCIIString() {
assertEquals("aaaeiiiiggnnsssuuy", StringNormalizer.removeAccentsWithApacheCommons("āăąēîïĩíĝġńñšŝśûůŷ"));
}
再测试包含连字和带笔划字符的字符串:
@Test
void givenStringWithNondecomposableUnicodeCharacters_whenRemoveAccentsWithApacheCommons_thenReturnModifiedString() {
assertEquals("lđħœ", StringNormalizer.removeAccentsWithApacheCommons("łđħœ"));
}
关键发现:StringUtils.stripAccents()
方法手动定义了拉丁字母ł和Ł的转换规则,但遗憾的是不处理其他连字。
7. Java字符分解的局限性
总结一下,我们发现某些字符没有定义分解规则。具体来说,Unicode没有为连字和带笔划字符定义分解规则,因此Java也无法规范化它们。如果需要去除这些字符,必须手动定义音译映射。
最后思考:是否真的需要去除重音和变音符号?对某些语言来说,去掉变音符号的字母可能失去意义。这种情况下,更好的做法是使用Collator
类比较字符串,并包含区域信息。
8. 结论
本文介绍了使用Java核心库和Apache Commons去除变音符号的方法,提供了多个示例,并展示了如何比较包含重音的文本,同时总结了处理这类文本时的注意事项。
完整源代码请参考GitHub仓库。