The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

如何透過 System.Text.Json 序列化輸出完整的 Unicode 字元

我最近在整理《重編國語辭典修訂本》最新版的資料檔,因為我預計輸出成跟 g0vmoedict-data 專案相容的 JSON 格式,而在輸出的時候卻發現這些中文字都被編碼了,但我希望能夠輸出成完整的 Unicode 字元。這才發現即便是 .NET 8 最新版,也無法輸出完整的 Unicode 字元,這篇文章就是記錄我如何解決這個問題的過程。

image

先來看一個簡單範例

首先,我先透過 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 1Plane 16 區段中,而 Plane 1 到 Plane 16 區段的碼位範圍是 U+010000U+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; }
}

相關連結

留言評論