了不起的Unicode
前言
提出一個小小的問題。大家按照自己的開發(fā)語言的特性,想想結(jié)果是啥?
"????♂?"這個Emoji的長度是多少?
如果,現(xiàn)在你用電腦閱讀本文,你可以輕松的打開xx PlayGround(xx可以為Js/Java/Rust等)。然后會得到屬于自己語言的結(jié)果。
如果,你現(xiàn)在手頭沒電腦,無法親自驗(yàn)證,我來直接告訴你答案。上述Emoji在每種語言環(huán)境下的結(jié)果都不統(tǒng)一。(當(dāng)然,有些語言內(nèi)核使用的機(jī)制一樣,結(jié)果可能也一樣)。
也就是說,在編程層面,這不是一種 「所見即所得」的表現(xiàn)形式。大家這里可能會納悶了,我要知道這個有啥?現(xiàn)在舉一個例子,在前端頁面中,我們總是會有統(tǒng)計用戶字?jǐn)?shù)的輸入框,但是由于用戶輸入了Emoji,從用戶的角度來看,這就是一個字符,但是在編程層面,如果不做一次解析的話,我們會得到千奇百怪的答案。
然后,我們再來一個讓人匪夷所思的例子。在瀏覽器中,嘗試復(fù)制如下代碼,然后進(jìn)行觀察答案。結(jié)果是不是又再一次顛覆你的所學(xué)。
"A?" === "?";
平時,我們時不時的會提到UTF-8/UTF-16/UTF-32它們到底是個啥?又有啥關(guān)系和區(qū)別呢?
還有其他的例子就不一一列舉了。之所以會出現(xiàn)這么多讓人匪夷所思的結(jié)果。一切的根源都是Unicode的鬧的。
所以,今天我們就來談?wù)勥@是何方神圣。
在2000多年前,我們那迷人的老祖宗,秦始皇,就實(shí)現(xiàn)了「車同軌,書同文」,劃破「地域障礙」,從而給不同地方的人在交流上開辟了新的空間。雖然,有些地方還存在「十里不同音,百里不通俗」的情況(我老家山西就是這種情況)。但是,在官方層面或者書面層面上,大家可以溝通無阻。
好了,天不早了,干點(diǎn)正事哇。
我們能所學(xué)到的知識點(diǎn)
- 前置知識點(diǎn)
- Unicode 是個啥?
- UTF-8 又是什么?
- UTF-32 問題
- Unicode 病癥
- 如何檢測擴(kuò)展形素簇
- "A?" !== "?" !== "?"
- Unicode 取決于區(qū)域設(shè)置
1. 前置知識點(diǎn)
「前置知識點(diǎn)」,只是做一個概念的介紹,不會做深度解釋。因?yàn)椋@些概念在下面文章中會有出現(xiàn),為了讓行文更加的順暢,所以將本該在文內(nèi)的概念解釋放到前面來。「如果大家對這些概念熟悉,可以直接忽略」同時,由于閱讀我文章的群體有很多,所以有些知識點(diǎn)可能「我視之若珍寶,爾視只如草芥,棄之如敝履」。以下知識點(diǎn),請「酌情使用」。
ASCll
ASCII[1](American Standard Code for Information Interchange)的縮寫,發(fā)音為ask-key。ASCII是一種用于表示字符的7位標(biāo)準(zhǔn)編碼,其中包括字母、數(shù)字和標(biāo)點(diǎn)符號。
圖片
7 位編碼允許計算機(jī)編碼總共128個字符,包括數(shù)字 0-9、大寫和小寫字母 A-Z 以及一些標(biāo)點(diǎn)符號。然而,這 128 位編碼僅適用于英語用戶。
ASCII 的功能
- ASCII的建立旨在實(shí)現(xiàn)各種數(shù)據(jù)處理設(shè)備之間的「兼容性」,從而使這些組件能夠成功地相互通信。
- ASCII使制造商能夠生產(chǎn)可以確保在計算機(jī)中正確運(yùn)行的組件。
- ASCII使人機(jī)互動。
ASCII 在計算機(jī)系統(tǒng)中的工作原理
當(dāng)我們按下鍵盤上的鍵,例如字母D時,電子信號被發(fā)送到計算機(jī)的CPU進(jìn)行處理和存儲在內(nèi)存中。「每個字符都被轉(zhuǎn)換為其對應(yīng)的二進(jìn)制形式」。計算機(jī)將字母處理為一個字節(jié),實(shí)際上是一系列電子狀態(tài)的開和關(guān)。當(dāng)計算機(jī)完成處理字節(jié)后,系統(tǒng)中安裝的軟件將字節(jié)轉(zhuǎn)換回,并在屏幕上顯示。字母 D 被轉(zhuǎn)換為01000100。
TextEncoder 和 TextDecoder
TextEncoder 和 TextDecoder 是 JavaScript 中用于處理字符編碼的「內(nèi)置對象」。它們通常用于在不同字符編碼之間進(jìn)行文本的編碼和解碼。
TextEncoder
- TextEncoder 是用于「將字符串文本編碼為字節(jié)數(shù)組」(通常是 UTF-8 編碼)的對象。
- 它提供了一個 encode() 方法,接受一個字符串作為參數(shù),并返回一個包含字節(jié)的 Uint8Array 對象。
- TextEncoder 用于將文本數(shù)據(jù)轉(zhuǎn)換為字節(jié)數(shù)據(jù),以便在網(wǎng)絡(luò)傳輸、文件讀寫或其他需要字節(jié)數(shù)據(jù)的情況下使用。
示例:
const encoder = new TextEncoder();
const text = "前端柒八九!";
const bytes = encoder.encode(text); // 將文本編碼為字節(jié)數(shù)組
TextDecoder
- TextDecoder 是用于將字節(jié)數(shù)組解碼為字符串文本的對象。
- 它提供了一個 decode() 方法,接受一個包含字節(jié)的 Uint8Array 對象,并返回相應(yīng)的字符串。
- TextDecoder 用于將字節(jié)數(shù)據(jù)還原為文本,通常用于處理來自網(wǎng)絡(luò)請求或文件的字節(jié)數(shù)據(jù)。
示例:
const decoder = new TextDecoder("UTF-8");
const bytes = new Uint8Array([
72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33,
]);
const text = decoder.decode(bytes); // 將字節(jié)數(shù)組解碼為字符串
這些對象在處理「多語言文本」、「字符編碼轉(zhuǎn)換」和處理「國際化內(nèi)容」時非常有用,使 JavaScript 能夠處理不同字符編碼之間的數(shù)據(jù)轉(zhuǎn)換。
Emoji
Emoji 是可以插入文字的圖形符號。
圖片
它是一個日語詞,e表示"絵",moji表示"文字"。連在一起,就是"絵文字"。
2010 年,Unicode 開始為 Emoji 分配碼點(diǎn)。也就是說,「現(xiàn)在的 Emoji 符號就是一個文字」,它會被渲染為圖形。
圖片
想了解更多,可以翻閱Emoji 簡介[2]
2. Unicode 是個啥?
Unicode是一個旨在統(tǒng)一所有人類語言(包括過去和現(xiàn)在的語言)并使它們與計算機(jī)兼容的標(biāo)準(zhǔn)。
Unicode 是一個將「不同字符分配給唯一編號的表格」。
例如:
- 拉丁字母 A 被分配編號 65。
- 阿拉伯字母 Seen ?是 1587。
- 片假名字母 Tu ツ 是 12484
- 音樂符號 G 調(diào)號 ?? 是 119070。
- ?? 是 128169。
Unicode 將這些編號稱為「碼位」(code points)。
由于這套準(zhǔn)則是全球都認(rèn)準(zhǔn)的,所以我們采用這套規(guī)則,就可以達(dá)到「書同文」的情況,來自不同語言環(huán)境下的人,可以閱讀彼此的文本。
有如下的關(guān)系鏈子。 一個Unicode對應(yīng)著一個字符,并且該字符擁有幾乎唯一的碼位。
Unicode === 字符 ? 碼位。
Unicode 有多大?
目前,「最大的已定義碼位」是0x10FFFF。(0x10FFFF 是一個十六進(jìn)制數(shù),將其轉(zhuǎn)換為十進(jìn)制,其值為 1,114,111。)這給我們提供了大約 110 萬個碼位的空間。
目前已定義了約 15%(約 170,000 個),另外 11%(為私人使用)已被保留。其余約 800,000 個碼位目前尚未分配,它們可能在未來成為字符。
大致如下圖所示:
圖片
- 大正方形 包含 65,536 個字符。
- 小正方形 包含 256 個字符。
- 整個 ASCII 字符集僅占位于左上角的小紅色正方形的一半。
私人使用區(qū)(Private Use)
私人使用區(qū)是為應(yīng)用程序開發(fā)人員保留的碼位,不會由 Unicode 本身定義。
例如,Unicode 中沒有為蘋果標(biāo)志保留位置,因此蘋果將它放在了 U+F8FF,這位于私人使用區(qū)。在任何其他字體中,它將呈現(xiàn)為缺失的字符 ??,但在與 macOS 一起提供的字體中,我們將看到蘋果圖標(biāo)。
私人使用區(qū)主要用于「圖標(biāo)字體」:
上面的圖標(biāo)都是文本格式
U+1F4A9 是什么意思?
這是一種寫碼位值的約定。前綴 U+表示 Unicode,而 1F4A9 是一個「十六進(jìn)制的碼位編號」。
U+1F4A9 具體表示的是 ??。(是不是我們多了一種很委婉的"表揚(yáng)別人"方式)
3. UTF-8 又是什么?
UTF-8 是一種「編碼方式」。
編碼是我們將碼位存儲在內(nèi)存中的方法。在互聯(lián)網(wǎng)和許多操作系統(tǒng)中,UTF-8是「默認(rèn)的文本編碼」。
最簡單的 Unicode 編碼是 UTF-32。它將碼位簡單地「存儲為 32 位整數(shù)」。因此,U+1F4A9 變成了 00 01 F4 A9,占用了「四個字節(jié)」。UTF-32 中的「任何其他碼位也將占用四個字節(jié)」。由于最高定義的碼位是 U+10FFFF,因此任何碼位都能夠容納。
- UTF-8通常用于存儲和傳輸文本
- UTF-16用于某些操作系統(tǒng)和編程語言
- UTF-16被許多系統(tǒng)采用。其中包括 Microsoft Windows、Objective-C、Java、JavaScript、.NET、Python 2等
- UTF-32適用于需要直接操作Unicode代碼點(diǎn)的情況
UTF-8 有多少字節(jié)?
UTF-8 是一種「可變長度」的編碼方式。
一個碼位可能被編碼為「一個到四個字節(jié)」的序列。
以下是 UTF-8 編碼的表示形式,「根據(jù)不同的碼位范圍使用不同數(shù)量的字節(jié)」
碼位范圍 | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
U+0000..007F | 0xxxxxxx | |||
U+0080..07FF | 110xxxxx | 10xxxxxx | ||
U+0800..FFFF | 1110xxxx | 10xxxxxx | 10xxxxxx | |
U+10000..10FFFF | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
這些規(guī)則描述了如何將不同碼位范圍內(nèi)的 Unicode 字符編碼為 UTF-8 字節(jié)序列。
如果將這些內(nèi)容與 Unicode 表結(jié)合起來,我們將看到
- 英語使用 1 個字節(jié)進(jìn)行編碼,
- 西里爾字母、拉丁歐洲語言、希伯來語和阿拉伯語需要 2 個字節(jié),
- 中文、日語、韓語、其他亞洲語言和表情符號需要 3 或 4 個字節(jié)。
以下是一些重要的要點(diǎn):
首先,UTF-8 與 ASCII 是「字節(jié)兼容」的。碼位 0..127,即舊的 ASCII 字符,使用一個字節(jié)進(jìn)行編碼,而且它們的字節(jié)表示完全相同。例如,U+0041(A,拉丁大寫字母 A)就是 41,一個字節(jié)。
任何純 ASCII 文本也是有效的 UTF-8 文本,而且「只使用碼位 0..127 的 UTF-8 文本可以直接讀取為 ASCII」。
其次,UTF-8 對于基本拉丁字符來說是「空間高效」的。
- 對于像 HTML 標(biāo)簽或 JSON 這樣的技術(shù)字符串來說,這是有意義的。
第三,UTF-8 內(nèi)置了「錯誤檢測」和「恢復(fù)功能」。
- 第一個字節(jié)的前綴總是與第 2 到第 4 個字節(jié)不同。這樣,我們始終可以確定是否正在查看完整和有效的 UTF-8 字節(jié)序列,或者是否有遺漏。
- 然后,我們可以通過向前或向后移動,直到找到正確序列的開頭來進(jìn)行糾正。
還有一些重要的結(jié)論:
- 我們「無法通過計算字節(jié)來確定字符串的長度」。
- 我們「無法隨機(jī)跳到字符串的中間并開始閱讀」。
- 我們無法通過在任意字節(jié)偏移處進(jìn)行「切割來獲取子字符串」,可能會切斷字符的一部分。
如果硬要這么做的話,系統(tǒng)會給你一個?。
“?”是什么?
U+FFFD,即「替換字符」(Replacement Character),只是 Unicode 表中的另一個碼位。應(yīng)用程序和庫可以在檢測到 Unicode 錯誤時使用它。
如果將碼位的一半切掉,那么另一半也就沒什么用了,除了顯示錯誤。這時就會使用?。
JS 版本
const text = "前端柒八九";
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
const partial = bytes.slice(0, 11);
const decoder = new TextDecoder("UTF-8");
const result = decoder.decode(partial);
console.log(result); // 輸出 "前端柒?"
Rust 版本
fn main() {
let text = "前端柒八九";
let bytes = text.as_bytes();
let partial = &bytes[0..11];
let result = String::from_utf8_lossy(partial);
println!("{}", result); // 輸出 "前端柒?"
}
在 JavaScript 中使用 TextEncoder 和 TextDecoder 來處理編碼,而在 Rust 中使用 String::from_utf8_lossy 來處理字節(jié)。它們的目標(biāo)是在 UTF-8 編碼中處理文本并「截取部分字節(jié)」。
4. UTF-32 問題
UTF-32 非常適用于處理碼位。它的編碼方式中,「每個碼位始終是 4 個字節(jié)」,那么strlen(s) == sizeof(s) / 4,substring(0, 3) == bytes[0, 12](上面代碼為偽代碼)等等。
問題在于,我們不想處理碼位。一個碼位即「不是一個書寫單位」,又并「不總是代表一個字符」。我們應(yīng)該處理的是擴(kuò)展形素簇(extended grapheme clusters),或簡稱為形素(graphemes)。
形素是在特定書寫系統(tǒng)的上下文中的「最小可區(qū)分」的書寫單位。
例如,? 是一個形素,e?也是一個形素。還有像?這樣的形素。基本上,「形素是用戶認(rèn)為是一個字符的單元」。
問題是,在 Unicode 中,一些形素是由「多個碼位編碼」的!
圖片
例如,e?(一個單一的形素)在 Unicode 中編碼為 e(U+0065 拉丁小寫字母 E)+ ′(U+0301 連接重音符)。兩個碼位!
它也可能不止兩個:
- ?? 是 U+2639 + U+FE0F
- ???? 是 U+1F468 + U+200D + U+1F3ED
- ????♀? 是 U+1F6B5 + U+1F3FB + U+200D + U+2640 + U+FE0F
- y?????????? 是 U+0079 + U+0316 + U+0320 + U+034D + U+0318 + U+0347 + U+0357 + U+030F + U+033D + U+030E + U+035E
即使在最寬的編碼 UTF-32 中,???? 仍需要「三個 4 字節(jié)單元」來進(jìn)行編碼。它仍然需要被「視為一個單獨(dú)的字符」。
我們可以將 Unicode 本身(沒有任何編碼)視為「可變長度」的。
擴(kuò)展形素簇(Extended Grapheme Cluster)是「一個或多個 Unicode 碼位的序列」,必須將其視為「一個單獨(dú)的、不可分割的字符。
因此,在「碼位級別」上:「不能只取序列的一部分,它總是應(yīng)該作為一個整體選擇、復(fù)制、編輯或刪除」。
不正確使用形素簇會導(dǎo)致像這樣的錯誤:
無論是否選擇UTF-32還是UTF-8在處理形素上遇到相似的問題。所以如何使用形素才是我們應(yīng)該關(guān)心的。
5. Unicode 病癥
上面的例子中大部分都是涉及到表情符號,這會給人一種錯覺。Unicode只有在表示表情符號時,會遇到問題。--其實(shí)不是。
擴(kuò)展形素簇也用于常見的語言。
例如:
- ?(德語)是一個單一字符,但包含多個碼位(U+006F U+0308)。
- ??(立陶宛語)是 U+00E1 U+0328。
- ?(韓語)是 U+1100 U+1161 U+11A8。
所以,問題不僅僅是表情符號。
"????♂?".length 是多少?
不同的編程語言給出了不同的結(jié)果。
Python 3:
>>> len("????♂?")
5
JavaScript / Java / C#:
>> "????♂?".length
7
Rust:
println!("{}", "????♂?".len());
// => 17
不同的語言使用不同的「內(nèi)部字符串」表示(UTF-32、UTF-16、UTF-8),并以存儲字符的單位(整數(shù)、短整數(shù)、字節(jié))來報告長度。
但是!如果你問任何不懂編程理論的人,他們會給你一個明確的答案:????♂? 字符串的長度是 1。
這就是擴(kuò)展形素簇的意義:「人們視為單一字符的內(nèi)容」。在這種情況下,????♂? 顯然是一個單一字符。
????♂? 由 5 個碼位組成(U+1F926 U+1F3FB U+200D U+2642 U+FE0F)僅僅是「實(shí)現(xiàn)細(xì)節(jié)」。它不應(yīng)該被分開,「不應(yīng)該被計為多個字符」,文本光標(biāo)不應(yīng)該定位在其中,不應(yīng)該被部分選擇,等等。
這是「文本的一個不可分割的單位」。在內(nèi)部,它可以被編碼為任何形式,但對于面向用戶的 API,應(yīng)該將其視為一個整體。
唯一正確處理此問題的現(xiàn)代語言是 Swift:
print("????♂?".count)
// => 1
而對于我們比較熟悉的JS和Rust,我們可以使用一些方式做一下封裝。
function visibleLength(str) {
return [...new Intl.Segmenter().segment(str)].length;
}
visibleLength("????♂?"); // 輸出結(jié)果為1
當(dāng)然,我們還可以校驗(yàn)其他的形素。
visibleLength("?"); // => 1
visibleLength("????"); // => 1
visibleLength("????????????"); // => 2
visibleLength("と日本語の文章"); // => 7
但是呢,Intl.Segmenter的兼容性不是很好。
如果,我們要實(shí)現(xiàn)多瀏覽器適配,我們可以找一些第三方的庫。
- graphemer[3]
- text-segmentation[4]
如果想了解更多細(xì)節(jié),可以參考JS 如何正確處理 Unicode[5]
對于Rust我們可以使用unicode_segmentation[6]crate。
extern crate unicode_segmentation; // "1.9.0"
use std::collections::HashSet;
use unicode_segmentation::UnicodeSegmentation;
fn count_unique_grapheme_clusters(s: &str) -> usize {
let is_extended = true;
s.graphemes(is_extended).collect::<HashSet<_>>().len()
}
fn main() {
assert_eq!(count_unique_grapheme_clusters(""), 0);
assert_eq!(count_unique_grapheme_clusters("????♂?"), 1);
assert_eq!(count_unique_grapheme_clusters("????"), 1);
}
6. 如何檢測擴(kuò)展形素簇
大多數(shù)編程語言選擇了簡單的方式,允許我們迭代字符串時使用 1-2-4 字節(jié)的塊,但「不支持直接處理擴(kuò)展形素簇」。
由于它是默認(rèn)方式,結(jié)果我們看到了損壞的字符串:
圖片
如果遇到這種問題,我們首先的就是應(yīng)該想到使用Unicode 庫。
使用庫
即使是像 strlen、indexOf 或 substring 這樣的基本操作也應(yīng)該使用 Unicode 庫!
例如:
- C/C++/Java:使用 ICU[7]。這是 Unicode 自身發(fā)布的庫,包含了關(guān)于文本分割的所有規(guī)則。
- Swift:只需使用標(biāo)準(zhǔn)庫。Swift 默認(rèn)情況下會正確處理。
- Javascript的話,我們上面提到過,可以使用瀏覽器內(nèi)置功能Intl.Segmenter或者graphemer/text-segmentation
- Rust而言,我們可以使用unicode_segmentation
不管選擇哪種方式,確保它使用的是「新版本」的 Unicode,因?yàn)樾嗡氐亩x會隨版本而變化。
Unicode 規(guī)則更新
從大約 2014 年開始,Unicode 每年都會發(fā)布其標(biāo)準(zhǔn)的重大修訂版本。
每年更新
圖片
隨之而來的不良反映就是,定義形素簇的規(guī)則每年也會發(fā)生變化。今天被認(rèn)為是由兩個或三個獨(dú)立碼位組成的序列,明天可能會成為一個形素簇!這種朝令夕改的做法,很是讓人深惡痛絕。
更糟糕的是,我們自己的應(yīng)用程序的不同版本可能運(yùn)行在不同的 Unicode 標(biāo)準(zhǔn)上,并報告不同的字符串長度!
7. "A?" !== "?" !== "?"
將其中任何一個復(fù)制到你的 JavaScript 控制臺:
"A?" === "?";
"?" === "?";
"A?" === "?";
你會得到讓你匪夷所思的答案。沒錯,它們的打印結(jié)果都是false。
還記得之前的,? 是由兩個碼位組成,U+006F U+0308 。基本上,Unicode 提供了「多種」編寫字符如 ? 或 ? 的方式。
- 通過將普通的拉丁字母 A 與一個組合字符組合成 ?,
- 或者使用已經(jīng)預(yù)先組合的碼位 U+00C5。
因?yàn)椋鼈儭缚雌饋硎窍嗤沟模ˋ? 與 ?),所以從用戶的角度,我們就「認(rèn)為它們應(yīng)該是相同」的,但結(jié)果卻和我們的想法大相徑庭。
這就是為什么我們需要規(guī)范化。有四種形式:
這里先從NFD和NFC介紹。
- NFD(Normalization Form C) 嘗試將一切都分解為最小可能的部分,并如果存在多個部分,則按照規(guī)范順序?qū)@些部分進(jìn)行排序。
它消除任何規(guī)范化差異,并生成一個「分解的結(jié)果」
- NFC(Normalization Form C),嘗試將一切組合成已經(jīng)預(yù)先組合的形式(如果存在)
它消除任何規(guī)范化差異,通常生成一個「合成的結(jié)果」
不同的形式用于不同的用例,以確保文本在不同的方式下都保持一致。所以,盡管"A?" !== "?" !== "?",但通過適當(dāng)?shù)囊?guī)范化,我們可以使它們等同。
圖片
對于某些字符,Unicode 中還存在多個版本。例如,有 U+00C5 帶有上面環(huán)圈的拉丁大寫字母 A,但還有外觀相同的 U+212B ?ngstr?m 符號。
這些字符在規(guī)范化過程中也會被替換,以確保它們的一致性。
圖片
NFD 和 NFC 被稱為“規(guī)范化規(guī)范”(canonical normalization)。另外兩種形式是“兼容規(guī)范化”(compatibility normalization):
- NFKD 試圖將「所有內(nèi)容分解」,并使用默認(rèn)形式替換視覺變體。
它消除規(guī)范化和兼容性差異,并生成一個分解的結(jié)果
- NFKC 試圖將「所有內(nèi)容組合」在一起,同時用默認(rèn)形式替換視覺變體。
它消除規(guī)范化和兼容性差異,并通常生成一個合成的結(jié)果
圖片
視覺變體是表示相同字符的獨(dú)立 Unicode 碼位,但它們應(yīng)該呈現(xiàn)不同的方式。比如,①、? 或 ??。
圖片
所有這些字符都有自己的碼位,但它們也都是Xs。
在比較字符串或搜索子字符串之前,進(jìn)行規(guī)范化!
`Unicode`規(guī)范化[8]傳送 ??
在JavaScript 中,我們可以使用 normalize() 方法來實(shí)現(xiàn) NFC(Normalization Form C)和 NFD(Normalization Form D)。
const str1 = "A?";
const str2 = "?";
const normalizedStr1 = str1.normalize("NFC"); // NFC 形式
const normalizedStr2 = str2.normalize("NFC"); // NFC 形式
console.log(normalizedStr1 === normalizedStr2); // true
上述代碼首先使用 normalize('NFC') 方法將兩個字符串都轉(zhuǎn)換為 NFC 形式,然后比較它們是否相等。這將使 "A?" 和 "?" 的比較結(jié)果為 true。
如果使用 NFD 形式,只需將 normalize('NFC') 更改為 normalize('NFD') 即可。
8. Unicode 取決于區(qū)域設(shè)置
俄羅斯名字「尼古拉」
圖片
在Unicode 中編碼為 U+041D 0438 043A 043E 043B 0430 0439。
保加利亞名字「尼古拉」
圖片
也寫成 U+041D 0438 043A 043E 043B 0430 0439。
它們的Unicode值完全一樣,但是所顯示的字體信息卻不盡相同。是不是有種小腦萎縮的感覺。
然后心中有一個 ??,計算機(jī)如何知道何時呈現(xiàn)保加利亞風(fēng)格的字形,何時使用俄羅斯的字形?
其實(shí),計算機(jī)也不知。Unicode 并不是一個完美的系統(tǒng),它有很多不足之處。其中一個問題是「將本應(yīng)呈現(xiàn)不同外觀的字形分配給相同的碼位」,比如西里爾字母的小寫字母 K 和保加利亞的小寫字母 K(都是 U+043A)。
針對一些表音語言這塊還能好點(diǎn),但是到了我們大亞洲,很多國家的文字都是「表意」的。許多漢字、日語和韓語表意字形的寫法都截然不同,但被分配了相同的碼位。
圖片
Unicode 的動機(jī)是為了「節(jié)省碼位空間」。渲染信息應(yīng)該在字符串外部以區(qū)域設(shè)置/語言元數(shù)據(jù)的方式傳遞。
在實(shí)踐中,依賴于區(qū)域設(shè)置帶來了許多問題:
- 作為元數(shù)據(jù),區(qū)域設(shè)置通常會丟失。
- 人們不限于使用「單一區(qū)域設(shè)置」。例如,我們可以閱讀和寫作中文,美國英語、英國英語、德語和俄語。
- 難以混合和匹配。比如在保加利亞文本中使用俄羅斯名字,反之亦然。
- 沒有地方可以指定區(qū)域設(shè)置。即使制作上面的兩個屏幕截圖也不容易,因?yàn)樵诖蠖鄶?shù)軟件中,沒有下拉菜單或文本輸入來更改區(qū)域設(shè)置。
9. 處理特殊語言
另一個不幸的例子是土耳其語中無點(diǎn) i 的 Unicode 處理。
與英語不同,土耳其語有兩種 I 變體:有點(diǎn)和無點(diǎn)。
Unicode 決定重用 ASCII 中的 I 和 i,并只添加了兩個新的碼位:? 和 ?。
這導(dǎo)致了在相同輸入上 toLowerCase/toUpperCase 表現(xiàn)不同:
var en_US = Locale.of("en", "US");
var tr = Locale.of("tr");
System.out.println("I".toLowerCase(en_US)); // => "i"
System.out.println("I".toLowerCase(tr)); // => "?"
System.out.println("i".toUpperCase(en_US)); // => "I"
System.out.println("i".toUpperCase(tr)); // => "?"
所以,我們在不知道字符串是用哪種語言編寫的情況下將字符串轉(zhuǎn)換為小寫,會出現(xiàn)問題。
如果我們項(xiàng)目中涉及到土耳其語的字符轉(zhuǎn)換,在 JS 中toLowerCase是達(dá)不到上面的要求的。因?yàn)椋贘avaScript中,toLowerCase方法默認(rèn)使用Unicode規(guī)范進(jìn)行轉(zhuǎn)換,根據(jù)Unicode的規(guī)范,大寫 I 被轉(zhuǎn)換為小寫 i,而不是 ?。這是因?yàn)镴avaScript的toLowerCase方法按照Unicode的標(biāo)準(zhǔn)工作。
要想使用JS正確處理上面的問題,我們就需要額外的 API.
"I".toLocaleLowerCase("tr-TR"); // => "?"
"i".toLocaleUpperCase("tr-TR"); // => "?"
我們也可以通過對String.prototype上做一層封裝。
String.prototype.turkishToUpper = function () {
var string = this;
var letters = { i: "?", ?: "?", ?: "?", ü: "ü", ?: "?", ?: "?", ?: "I" };
string = string.replace(/(([i???ü??]))+/g, function (letter) {
return letters[letter];
});
return string.toUpperCase();
};
String.prototype.turkishToLower = function () {
var string = this;
var letters = { ?: "i", I: "?", ?: "?", ?: "?", ü: "ü", ?: "?", ?: "?" };
string = string.replace(/(([?I??ü??]))+/g, function (letter) {
return letters[letter];
});
return string.toLowerCase();
};
// 代碼演示
"D?N?".turkishToLower(); // => din?
"DIN?".turkishToLower(); // => d?n?
這樣就可以正確規(guī)避JS針對土耳其語言中的準(zhǔn)換問題。
在Rust中,我們可以使用如下代碼:
fn turkish_to_upper(input: &str) -> String {
let letters = [
('i', "?"),
('?', "?"),
('?', "?"),
('ü', "ü"),
('?', "?"),
('?', "?"),
('?', "I"),
];
let mut result = String::new();
for c in input.chars() {
let mut found = false;
for &(source, target) in &letters {
if c == source {
result.push_str(target);
found = true;
break;
}
}
if !found {
result.push(c);
}
}
result.to_uppercase()
}
fn turkish_to_lower(input: &str) -> String {
let letters = [
('?', "i"),
('I', "?"),
('?', "?"),
('?', "?"),
('ü', "ü"),
('?', "?"),
('?', "?"),
];
let mut result = String::new();
for c in input.chars() {
let mut found = false;
for &(source, target) in &letters {
if c == source {
result.push_str(target);
found = true;
break;
}
}
if !found {
result.push(c);
}
}
result.to_lowercase()
}
fn main() {
let input = "???ü???";
let upper_result = turkish_to_upper(input);
let lower_result = turkish_to_lower(input);
println!("Upper: {}", upper_result); //Upper: ???ü??I
println!("Lower: {}", lower_result); // Lower: i??ü???
}
Reference
[1]ASCII:https://cikgucandoit.wordpress.com/what-is-ascll/
[2]Emoji 簡介:https://www.ruanyifeng.com/blog/2017/04/emoji.html
[3]graphemer:https://github.com/flmnt/graphemer
[4]text-segmentation:https://github.com/niklasvh/text-segmentation
[5]JS 如何正確處理 Unicode:https://flaviocopes.com/javascript-unicode/
[6]unicode_segmentation:https://docs.rs/unicode-segmentation/latest/unicode_segmentation/
[7]ICU:https://github.com/unicode-org/icu
[8]Unicode規(guī)范化:https://www.unicode.org/glossary/