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个字符。这个暂且没找到什么好办法。

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

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