dotNET: Rune、StringInfo还有Unicode Normalization

之前就很在意字符编码之类的问题,但总是跟蹦豆似的,用到什么说什么,也没说得很清楚。趁现在有点眉目,捡三个主题说个痛快得了。

环境

鸟枪换炮了。不过还是在Windows上,以后或许会试试WSL 2。

操作系统Win10 2004,PowerShell 7.0.1($PSVersionTable.PSVersion.ToString())。

足够新了,至少我们可以说说System.Text.Rune

参考

正文

Rune

这个入呢 玉呢 撸耐Rune,印象中是从Go那边听说的,然后又有展望一类的内容,我寻思,哇,dotNET这边是不是得整点什么了。

他 们 整 了

虽然放在了System.Text.Rune里,不如预期那样。

但是吧,但是,此Rune非彼Rune,如官方文档所说:

However, the .NET Rune type is not the equivalent of the Go rune type. In Go, the rune type is an alias for int32. A Go rune is intended to represent a Unicode code point, but it can be any 32-bit value, including surrogate code points and values that are not legal Unicode code points.

彳亍口巴

末了儿,只是用了这个名字而已。实际上,一个Rune实例代表了一个Unicode scalar value——即是说,用code point表述的话,除了surrogate之外的都是scalar。

得,又来,scalar,s割啦?啧啊,整点例子得了:

# 且整点儿BMP内的活

# 一般的char
$c = [char]0x7684
Write-Host $c
# -> 的
[char]::GetUnicodeCategory($c)
# -> OtherLetter

# 让Rune整一手
$r = [System.Text.Rune]::new($c)
Write-Host $r
# -> 的
[System.Text.Rune]::GetUnicodeCategory($r)
# -> OtherLetter

看着没什么不同?这就对了。(心虚)

想想看,dotNET的char的取值范围本质上如何?

[int]([char]::MinValue)
# -> 0
[int]([char]::MaxValue)
# -> 65535
[UInt16]::MinValue -eq [int]([char]::MinValue)
# -> True
[UInt16]::MaxValue -eq [int]([char]::MaxValue)
# -> True

很自然的就有个问题:咱们现在的Unicode可不止这么点儿东↗西!

由于历史原因,dotNET在设计上顾及的是UTF-16,这就是说牵扯到了代理对(surrogate pair)的问题:

$s = "`u{20000}"
Write-Host $s
# -> 𠀀
# 这是 CJK Unified Ideographs Extension B / 中日韩统一表意文字扩展区B / CJK統合漢字拡張B 的第一个字符哦~
$charArray = $s.ToCharArray()
[char]::IsHighSurrogate($charArray[0])
# -> True
[char]::IsLowSurrogate($charArray[1])
# -> True

哈哈鹅。(苦笑)

这倒是有了Rune的用武之地:

$r = [System.Text.Rune]::new(0x20000)
# 另外也可以用[System.Text.Rune]::new($charArray[0], $charArray[1]),也OK
Write-Host $r
# -> 𠀀
[System.Text.Rune]::GetUnicodeCategory($r)
# -> OtherLetter

还是OtherLetter么,倒没错就是。

其实,可以把Rune想成char的补完,暂且这样就好。

实际使用时或许要使用String.EnumerateRunes,比如这样:

foreach ($r in '🎢右臂有个鸟🎢'.EnumerateRunes()) {
  Write-Host ("U+{0:X}" -f $r.Value)
}
# -> U+1F3A2
# -> U+53F3
# -> U+81C2
# -> U+6709
# -> U+4E2A
# -> U+9E1F
# -> U+1F3A2

说实在的,我有点失望,不过至少它做了该做的。或许w

另外,如果遇到不合法的代理对会怎样呢:

# 还是那个“𠀀”
$s = "`u{20000}"
# high surrogate
$s0 = [string]$s[0]
@($s0.EnumerateRunes())[0].Value
# -> 65533
# low surrogate
$s1 = [string]$s[1]
@($s1.EnumerateRunes())[0].Value
# -> 65533
# 将这两个随意组合起来
@(($s1 + $s0).EnumerateRunes())[0].Value
# -> 65533
@(($s1 + $s0).EnumerateRunes())[1].Value
# -> 65533
# 会被当成两个Rune看待
# 后略
# 自然的:
@(($s0 + $s1).EnumerateRunes())[0].Value
# -> 131072
# 合法就没有问题

那,这65533是什么呢:

[System.Text.Rune]::ReplacementChar.Value
# -> 65533

占位符是吧。

总之需要当心的一点是,非scalar(自然就是surrogate)会被Rune处理成ReplacementChar(这东西本身是个scalar),这自然不可逆了。虽然可以利用这点做些什么。

StringInfo

StringInfo……其实我想说TextElement的,毕竟GetTextElementEnumerator用得更多,不过这个类确实叫System.Globalization.StringInfo。行吧。

哪怕TextElement这样的说法也不是很恰当?官方文档里提到了

.NET defines a text element as a unit of text that is displayed as a single character, that is, a grapheme.

是嘛……又来新东西,grapheme,啧啊。这东西中文称字素,日文称書記素,简单讲就是我们通常认为的“字儿”。

原本还想说点什么,现在想想,算了,直接上代码吧:

# 汉喃“𡨸國語”的国语字表记。但都用组合文字表记,而不用现成的预组字符
# 这不是通常应该的写法,暂且用来当例子而已
$s = "Chu`u{031B}`u{0303} Quo`u{0302}`u{0301}c Ngu`u{031B}`u{0303}"
foreach ($p in [System.Globalization.StringInfo]::GetTextElementEnumerator($s)) {
  foreach ($r in $p.EnumerateRunes()) {
    Write-Host ("U+{0:X4}" -f $r.Value + ' ') -NoNewline
  }
  Write-Host
}
# -> U+0043
# -> U+0068
# -> U+0075 U+031B U+0303
# -> U+0020
# -> U+0051
# -> U+0075
# -> U+006F U+0302 U+0301
# -> U+0063
# -> U+0020
# -> U+004E
# -> U+0067
# -> U+0075 U+031B U+0303
@([System.Globalization.StringInfo]::GetTextElementEnumerator($s)).Length
# -> 12

如何?至少它是12个“字”吧。

如果有个需求,说是要一个字一个字蹦,或许这样会好些?

合法代理对也是没问题的:

# 合略仮名の「𬼂」
# 在CJK Unified Ideographs Extension F中
$s = "`u{2CF02}"
# 另外,用“[char]::ConvertFromUtf32(0x2CF02)”也可以——至少用PowerShell 5的话建议这么做
$s.Length
# -> 2
@([System.Globalization.StringInfo]::GetTextElementEnumerator($s)).Length
# -> 1

但如果是不合法的会怎样:

# $s内容承上
# 造一些不合法的代理对看看
$s00 = $s[0] + $s[0]
$s11 = $s[1] + $s[1]
$s10 = $s[1] + $s[0]
$s00.Length
# -> 2
@([System.Globalization.StringInfo]::GetTextElementEnumerator($s00)).Length
# -> 2
# 其他的也是一样效果

至少它不报错w

但是如果用👨‍👩‍👧‍👦这样的组合emoji呢?哪怕TextElementEnumerator也会认为它是7个字符。这个暂且没找到什么好办法。

更新(2020/09/13)

各位,各位,时代变了。

之前我翻了一下.NET 5.0的breaking changes,发现有这个:StringInfo and TextElementEnumerator are now UAX29-compliant

而且现在很轻易就能发现它的影响。现在去Win 10的商店里就能下载安装PowerShell Preview,当前版本是7.1.0-preview.7,release note见,而它在用的是.NET 5 preview 8。

行吧,试试呗。方便起见,照着breaking changes里的例子做一下:

# 事先复制了“👨‍👩‍👧‍👦”在剪贴板里
[string] $s = Get-Clipboard
# 整个TextElementEnumerator出来
$e = [System.Globalization.StringInfo]::GetTextElementEnumerator($s)
# 看看怎样
[int] $i = 0
while ($e.MoveNext()) { Write-Host "Grapheme $([string](++$i)): `"$($e.Current)`"" }

# PowerShell 7.0.3の場合
<#
Grapheme 1: "👨"
Grapheme 2: "‍"
Grapheme 3: "👩"
Grapheme 4: "‍"
Grapheme 5: "👧"
Grapheme 6: "‍"
Grapheme 7: "👦"
#>

# PowerShell 7.1.0-preview.7の場合
<#
Grapheme 1: "👨‍👩‍👧‍👦"
#>

是我们想要的吧。

再回头稍微看一下breaking changes,“Globalization APIs use ICU libraries on Windows”:

Previously, .NET libraries used National Language Support (NLS) APIs for globalization functionality. For example, NLS functions were used to get culture data, such as date and time format patterns, compare strings, and perform string casing in the appropriate culture.

Starting in .NET 5.0, if an app is running on Windows 10 May 2019 Update or later, .NET libraries use ICU globalization APIs. Windows 10 May 2019 Update and later versions ship with the ICU native library. If the .NET runtime can't load ICU, it uses NLS instead.

唔,这算是历史性进步么w

Unicode Normalization

说回那个“𡨸國語”的国语字表记,我当时用的是组合文字表记的,并不是通常的做法。

那么行吧,合起来如何:

# 咱不重复了呗
$s = "Chu`u{031B}`u{0303} Quo`u{0302}`u{0301}c Ngu`u{031B}`u{0303}"
Write-Host $s
# -> Chữ Quốc Ngữ
$s.Length
# -> 18

# 正规化!
$sFormC = $s.Normalize([System.Text.NormalizationForm]::FormC)
Write-Host $sFormC
# -> Chữ Quốc Ngữ
$sFormC.Length
# -> 12

或许那两个“𡨸國語”看起来一样?一个是含有组合文字的,另一个则全部是预组字符。试试把它们复制进文本编辑器里删一删看看吧。

虽然可以对比上面的StringInfo相关内容来看,但要注意,grapheme这个物件和normalization这个手段并无直接关联。

刚才说“全部是预组字符”,要是正规化时没有对应的预组字符,那,搞不定就只是搞不定,哪怕它整个是一个grapheme:

# 在漫画里容易看到的那种带浊点的あ
$s = "あ`u{3099}"
# 
Write-Host $s
# -> あ゙
$s.Length
# -> 2
$s.Normalize([System.Text.NormalizationForm]::FormC) -eq $s
# -> True
# 但说它是“一个grapheme”,倒没错嚯
@([System.Globalization.StringInfo]::GetTextElementEnumerator($s)).Length
# -> 1

这个あ゙是不是挺有意思的w

这里,FormCC表示组合,相应的就有FormD,就是拆散;另外还有FormKCFormKD,不仅复杂,还有其他用途,建议查看Unicode正規化一文,能拿点例子来玩也是好的。

后记

和这些东西简直如同在缠斗一样。

然而毕竟相当实用,掌握一下并不坏,是吧w

InSb

InSb

只是跟工作和生活相关的记录