我最近在整理《重編國語辭典修訂本》最新版的資料檔,因為我預計輸出成跟 g0v 的 moedict-data 專案相容的 JSON 格式,而在輸出的時候卻發現這些中文字都被編碼了,但我希望能夠輸出成完整的 Unicode 字元。這才發現即便是 .NET 8 最新版,也無法輸出完整的 Unicode 字元,這篇文章就是記錄我如何解決這個問題的過程。
先來看一個簡單範例
首先,我先透過 LINQPad 展示一個簡單的範例程式:
https://share.linqpad.net/9q68tjvr.linq
void Main()
{
var data = """
{
"title": "這個字是𩱚"
}
""";
var obj = JsonSerializer.Deserialize<Item>(data);
var json = JsonSerializer.Serialize(obj, options: new JsonSerializerOptions()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
json.Dump();
}
class Item
{
[JsonPropertyName("title")]
public string Title { get; set; }
}
從上述程式你可以看到我的 JsonSerializerOptions
已經指定了 Encoder
設定,並且我透過 UnicodeRanges.All
設定允許了完整的 Unicode 字元範圍,但是當我執行這個程式時,卻發現輸出的 JSON 仍然有部分文字是編碼過的:
{
"title": "這個字是\uD867\uDC5A"
}
簡單來說「𩱚」這個字,在 Unicode 的 Code Page (碼位) 是 U+29C5A
,已經超出了 BMP (Basic Multilingual Plane) (基本多語言區段) 的範圍 (U+FFFF
),因此必須要改用 Surrogate Pair
(代理對) 的方式來表示這個字元。也就是說,你要用兩個 UTF-16 字元,才能呈現一個字。
注意: JavaScript 的所有字串預設都是以 UTF-16
呈現的。
補充 Unicode 小知識
在 Unicode 標準中,UTF-8
被設計成可包含 2 的 31 次方 (2,147,483,648) 個碼位 (共 32,768 個區段)。
備註: 這裡的「碼位」(Code Point) 是指 Unicode 中的一個數字,用來表示一個字元。而「區段」(Plane) 是指 Unicode 中的一個區段,用來表示一個範圍的碼位。一個「區段」(Plane) 是由 65,536 (2 的 16 次方) 個連續的「碼位」組成。
Unicode 可以用 U+hhhhhh
來表示一個文字,其中前兩位代表「區段」(Plane),由於 UTF-16 的限制,目前制訂了 17 個區段,由數字 0 到 16 編號,對應 16 進制的 00
~ 10
(U+hh
),所以每個區段可以包含 65,536 (2 的 16 次方) 個碼位。
目前 Plane 0
區段,又稱 BMP (Basic Multilingual Plane) (基本多語言區段) 已經都定義完成,基本上已經涵蓋了全世界大多數文字。而在 BMP 之外的區段,則是為了支援一些特殊的文字符號,例如 Emoji、古代文字、圖形符號等等,都會被放在 Plane 1
到 Plane 16
區段中,而 Plane 1 到 Plane 16 區段的碼位範圍是 U+010000
到 U+10FFFF
,又稱為「補充區段」(Supplementary Planes)。
Unicode 中的最後一個碼位是 Plane 16 中的最後一個碼位,U+10FFFF
。截至 Unicode 版本 15.1,五個區段已分配了碼位 (字元),七個區段被命名。
基本上,超出 BMP 區段的文字,在 UTF-16 的表示法中,需要用到兩個 16 位元的碼位來表示,這兩個 16 位元的碼位組合在一起,就稱為 Surrogate Pair
(代理對)。
繼續我們的範例
由於「𩱚」(U+29C5A
) 這個字必須用 Surrogate Pair
(代理對) 來呈現,所以 UTF-16 的方式如下:
D867 DC5A
而 JavaScript 或 JSON 的字串表示法為:
"\uD867\uDC5A"
這就是為什麼 .NET 8 的 System.Text.Json
會將這個字元轉換成 \uD867\uDC5A
的原因。
{
"title": "這個字是\uD867\uDC5A"
}
備註: 你可以到 Unihan Database 查詢更多關於 Unicode 每個字元的詳細資訊。
解決方案
我後來有找到在 dotnet/runtime 專案下的 [API Proposal]: UnicodeJsonEncoder · Issue #87153 有列管了一個尚未完成的需求。基本上,這個需求在 .NET 是無法達成的,目前沒辦法,未來也不知道什麼時候會做好!有興趣的人可以訂閱一下這個 Issues 的更新通知。
我的解決方案稍微比較髒一點點,基本上就是直接對最後轉出的 JSON 字串進行處理,將輸出的 \uD867\uDC5A
硬轉成一個標準的 Unicode Surrogate Pair 字元,這樣就可以得到完整的 Unicode 字元了。
首先,你要先理解 Surrogate Pairs 其實是由兩個 UTF-16 字元所組成,而且在 Unicode 中,這兩個字元的範圍有明確的定義:
- 高位代理 (high surrogate) 從
0xD800
開始,到 0xDBFF
結束。
- 低位代理 (low surrogate) 從
0xDC00
開始,到 0xDFFF
結束。
轉換為 Regular Expression (正則表示式) 的 Pattern 就是:
\uD[89AB][0-9A-F][0-9A-F]\uD[cdef][0-9a-f][0-9a-f]
寫成 C# 程式碼就是這樣:
// 僅取代 surrogate pairs 的 Unicode 字元
json = Regex.Replace(json,
@"\\uD[89AB][0-9A-F][0-9A-F]\\uD[CDEF][0-9A-F][0-9A-F]",
(match) => Regex.Unescape(match.Value),
RegexOptions.IgnoreCase);
以下就是最終的結果:
https://share.linqpad.net/uvjecr3v.linq
void Main()
{
var data = """
{
"title": "這個字是𩱚"
}
""";
var obj = JsonSerializer.Deserialize<Item>(data);
var json = JsonSerializer.Serialize(obj, options: new JsonSerializerOptions()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.Create(UnicodeRanges.All),
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
json.Dump();
// 僅取代 surrogate pairs 的 Unicode 字元
json = Regex.Replace(json,
@"\\uD[89AB][0-9A-F][0-9A-F]\\uD[CDEF][0-9A-F][0-9A-F]",
(match) => Regex.Unescape(match.Value),
RegexOptions.IgnoreCase);
json.Dump();
}
class Item
{
[JsonPropertyName("title")]
public string Title { get; set; }
}
相關連結