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

我最近在整理《[重編國語辭典修訂本](https://language.moe.gov.tw/001/Upload/Files/site_content/M0001/respub/index.html)》最新版的資料檔，因為我預計輸出成跟 [g0v](https://g0v.tw/) 的 [moedict-data](https://github.com/g0v/moedict-data) 專案相容的 JSON 格式，而在輸出的時候卻發現這些中文字都被編碼了，但我希望能夠輸出成完整的 Unicode 字元。這才發現即便是 .NET 8 最新版，也無法輸出完整的 Unicode 字元，這篇文章就是記錄我如何解決這個問題的過程。

![image](https://stwillblogassets.blob.core.windows.net/files/images/external/stwillblogassets.blob.core.windows.net/1d65d749e8da4520e124-fa2d0c95-b7b2-40ec-bfeb-ac011075ea36.webp)

### 先來看一個簡單範例

首先，我先透過 [LINQPad](https://www.linqpad.net/) 展示一個簡單的範例程式：

> [https://share.linqpad.net/9q68tjvr.linq](https://share.linqpad.net/9q68tjvr.linq)

```csharp
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 仍然有部分文字是編碼過的：

```json
{
  "title": "這個字是\uD867\uDC5A"
}
```

簡單來說「𩱚」這個字，在 Unicode 的 Code Page (碼位) 是 `U+29C5A`，已經超出了 [BMP](https://zh.wikipedia.org/zh-tw/Unicode%E5%AD%97%E7%AC%A6%E5%B9%B3%E9%9D%A2%E6%98%A0%E5%B0%84) (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](https://en.wikipedia.org/wiki/Plane_\(Unicode\)#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`](http://www.unicode.org/cgi-bin/GetUnihanData.pl?codepoint=29C5A)) 這個字必須用 `Surrogate Pair` (代理對) 來呈現，所以 UTF-16 的方式如下：

```
D867 DC5A
```

而 JavaScript 或 JSON 的字串表示法為：

```javascript
"\uD867\uDC5A"
```

這就是為什麼 .NET 8 的 `System.Text.Json` 會將這個字元轉換成 `\uD867\uDC5A` 的原因。

```json
{
  "title": "這個字是\uD867\uDC5A"
}
```

> 備註: 你可以到 [Unihan Database](https://www.unicode.org/charts/unihan.html) 查詢更多關於 Unicode 每個字元的詳細資訊。

### 解決方案

我後來有找到在 [dotnet/runtime](https://github.com/dotnet/runtime) 專案下的 [\[API Proposal\]: UnicodeJsonEncoder · Issue #87153](https://github.com/dotnet/runtime/issues/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# 程式碼就是這樣：

```csharp
// 僅取代 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](https://share.linqpad.net/uvjecr3v.linq)

```csharp
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; }
}
```

### 相關連結

-   [Plane (Unicode) - Wikipedia](https://en.wikipedia.org/wiki/Plane_\(Unicode\))
-   [UTF-16 - Wikipedia](https://en.wikipedia.org/wiki/UTF-16)
-   [Unihan Database Lookup](https://www.unicode.org/charts/unihan.html)
