# CJK 生僻字實戰：當系統印不出學生的名字


## 前言

你有遇過系統印出來的名字變成方框「□」的情況嗎？

最近在維護一個考試報名系統時，遇到了一個真實的問題：學生的姓名中有「生僻字」，也就是一般字型檔裡沒有的罕用漢字。這些字在資料庫裡存得好好的，螢幕上有的字型能顯示、有的不行，但最關鍵的是——**PDF 證書和收據上印不出來**。

這篇文章記錄了整個排查和解決的過程，涉及 Unicode 的 Private Use Area、全字庫字型、PDFKit 的字型 Fallback 機制，以及 PostgreSQL 的字元儲存。

&lt;!--more--&gt;

## 先備知識：Unicode 到底是什麼？

### 電腦只認數字

電腦底層只懂 0 和 1，所以要顯示文字，需要兩件事：

1. **編號表**：規定「哪個數字 = 哪個字」
2. **字型檔**：規定「這個數字畫出來長什麼樣子」

### 從 ASCII 到 Unicode

最早美國人制定了 **ASCII**，只有 128 個位置，連英文&#43;數字&#43;符號就用完了，中文完全沒位置。

後來各國各搞各的——台灣搞了 **Big5**，中國搞了 **GB2312**，日本搞了 **Shift_JIS**。問題是同一個數字在不同編碼表代表不同的字，跨系統就變亂碼。

**Unicode** 就是為了解決這個問題：**給全世界每一個字一個唯一的編號**，不再打架。

```
U&#43;0041 = A
U&#43;4E2D = 中
U&#43;798E = 禎
```

### Unicode ≠ UTF-8

很多人搞混這兩個，其實它們是不同層次的東西：

- **Unicode** = 編號表（「禎」的編號是 798E）
- **UTF-8** = 儲存方式（798E 這個編號在硬碟上要用幾個 byte 存？怎麼存？）

```
「A」= U&#43;0041
  UTF-8 儲存：41                （1 byte）

「中」= U&#43;4E2D  
  UTF-8 儲存：E4 B8 AD          （3 bytes）

 PUA 罕用字 = U&#43;FBF34
  UTF-8 儲存：F3 BB BC B4       （4 bytes）
```

越罕見的字編號越大，UTF-8 需要越多 byte 來儲存。但這只影響儲存空間，不影響「一個字就是一個字」的事實。PostgreSQL 的 `VARCHAR(255)` 是算**字元數**不是 byte 數，所以不管多罕見的字都存得進去。

### Unicode 的結構——一棟超大公寓

把 Unicode 想成一棟大樓，不同樓層住著不同的字：

```
┌──────────────────────────────────────────────┐
│  0 樓（BMP）U&#43;0000 ~ U&#43;FFFF                 │
│  ├── 英文、數字、標點      U&#43;0000 ~ U&#43;007F   │
│  ├── 常用中日韓漢字        U&#43;4E00 ~ U&#43;9FFF   │ ← 一般的「禎」住這裡
│  ├── CJK 相容漢字          U&#43;F900 ~ U&#43;FAFF   │ ← 異體字住這裡
│  └── BMP 使用者造字區      U&#43;E000 ~ U&#43;F8FF   │
│                                              │
│  2 樓（SMP）U&#43;10000 ~ U&#43;1FFFF               │
│  └── 表情符號、古文字等                       │
│                                              │
│  ...                                         │
│                                              │
│  15 樓（PUA-A）U&#43;F0000 ~ U&#43;FFFFF            │
│  └── 使用者造字區                             │ ← 全字庫罕用字住這裡
│                                              │
│  16 樓（PUA-B）U&#43;100000 ~ U&#43;10FFFD          │
│  └── 使用者造字區                             │
└──────────────────────────────────────────────┘
```

重點來了：**一般字型只做到 0 樓的常用區**。15 樓以上的 PUA（Private Use Area，使用者造字區），是由各組織自己定義的，只有特定字型才認得。台灣的「全字庫」就把 Unicode 還沒收錄的罕用字放在 15 樓。

理解了這個結構，接下來的問題就容易懂了。

## 問題背景

系統需要產生三種 PDF 文件：准考證、成績證書、收據。每份文件上都需要印出考生的中文姓名。大部分姓名沒有問題，但偶爾會有學生的名字包含罕用字，例如：

- 「禎」的異體字（左邊是完整的「示」而非簡化的「礻」）
- 其他在常見字型中找不到的字

這些字有一個共同的特徵：**它們不在 Unicode 的標準 CJK 統一漢字區塊中**。

## 罕用字住在哪裡？

有了前面的大樓概念，我們來看這次遇到的字具體住在哪：

| 字 | Unicode 碼位 | 住在哪層 | 說明 |
|---|---|---|---|
| 禎（礻旁） | U&#43;798E | 0 樓，常用漢字區 | 所有字型都有 |
| 禎（示旁，相容版） | U&#43;FA53 | 0 樓，CJK 相容漢字區 | 異體字，NFKC 會轉成 U&#43;798E |
| 禎（示旁，全字庫版） | U&#43;FBF34 | 15 樓，PUA 造字區 | 只有全字庫字型認得 |

「禎」的「示」旁異體字在全字庫中的對應是：

```
CNS 碼：13-7275
Unicode PUA：U&#43;FBF34（十進位 1031988）
```

## 實際遇到的兩種情況

### 情況一：CJK 相容漢字（U&#43;FA53）

資料庫中存的碼位是 U&#43;FA53，屬於 CJK Compatibility Ideographs。這類碼位的特殊之處在於，當你對它做 **NFKC 正規化**（Normalization Form KC）時，會被自動轉換成標準碼位：

```typescript
// U&#43;FA53 (示旁的禎) → NFKC → U&#43;798E (礻旁的禎)
const original = &#39;\uFA53&#39;;
const normalized = original.normalize(&#39;NFKC&#39;);
console.log(original === normalized); // false — 字被換掉了！
```

這就是為什麼之前程式碼中的 `normalizeCJK` 函式會讓名字變成錯誤的字形。

### 情況二：PUA 字元（U&#43;FBF34）

另一個學生的名字包含全字庫自定義的 PUA 字元 U&#43;FBF34。這類字的特性是：

- 只有全字庫字型認得這個碼位
- 系統內建字型（如新細明體、黑體）無法顯示
- NFKC 正規化不會改變它（因為 PUA 沒有標準對應）

## 解決方案

### 1. 資料庫儲存

PostgreSQL 使用 UTF-8 編碼，**天生支援所有 Unicode 碼位**，包括 PUA。存入 PUA 字元可以用 `chr()` 函式：

```sql
-- U&#43;FBF34 = 十進位 1031988
UPDATE signup_info 
SET name = &#39;郭于&#39; || chr(1031988) 
WHERE id = 28292;

-- 驗證
SELECT id, name, length(name), ascii(substring(name from 3 for 1)) 
FROM signup_info WHERE id = 28292;
-- 結果：length = 3, ascii = 1031988 ✓
```

&gt; 注意：DBeaver 等工具可能無法正確顯示 PUA 字元（因為工具使用的字型沒有這個字），但資料本身是正確的。用 `ascii()` 函式驗證碼位即可。

### 2. 字型準備

台灣的[全字庫](https://data.gov.tw/dataset/5961)提供了完整的字型檔，以楷體為例：

| 字型檔 | 涵蓋範圍 |
|--------|----------|
| TW-Kai-98_1.ttf | CNS 第 1-2 字面（常用字） |
| TW-Kai-Plus-98_1.ttf | CNS 第 3 字面以上（含 PUA 罕用字） |
| TW-Kai-Ext-B-98_1.ttf | CJK Extension B |

關鍵：**PUA 字元（如 U&#43;FBF34）在 `TW-Kai-Plus` 裡面**，不是在主字型 `TW-Kai` 裡。

### 3. PDFKit 字型 Fallback 機制

PDFKit 不像瀏覽器會自動嘗試多個字型。如果指定的字型沒有某個字，它會畫出 .notdef 字形（空白或方框），**不會自動 fallback**。

#### 踩過的坑：`widthOfString` 不可靠

一開始我們用 `widthOfString` 來判斷字型是否支援某個字：

```typescript
// ❌ 不可靠的做法
doc.font(&#39;TW-Kai&#39;).fontSize(48);
const width = doc.widthOfString(char);
if (width &gt; 0) {
    // 以為字型有這個字，但其實 .notdef 字形也有寬度！
    doc.text(char, x, y);
}
```

問題是：**即使字型沒有這個字，.notdef 字形的寬度也大於 0**，所以判斷永遠為 true，fallback 永遠不會觸發。

#### 正確做法：`hasGlyphForCodePoint`

PDFKit 內部使用 fontkit 來處理字型，可以透過底層 API 精確判斷：

```typescript
function fontHasGlyph(doc: any, char: string): boolean {
    try {
        const codePoint = char.codePointAt(0);
        return doc._font.font.hasGlyphForCodePoint(codePoint);
    } catch (e) {
        return false;
    }
}
```

#### 完整的 Fallback 實作

```typescript
function drawTextWithFallback(
    doc: any, text: string, 
    x: number, y: number, fontSize: number
) {
    let currentX = x;
    const fonts = [&#39;TW-Kai&#39;, &#39;TW-Kai-Plus&#39;, &#39;TW-Kai-ExtB&#39;];

    for (const char of text) {
        let drawn = false;

        // 第一輪：用原始字元嘗試所有字型
        for (const fontName of fonts) {
            try {
                doc.font(fontName).fontSize(fontSize);
                if (fontHasGlyph(doc, char)) {
                    const charWidth = doc.widthOfString(char);
                    doc.text(char, currentX, y, { 
                        lineBreak: false, continued: false 
                    });
                    currentX &#43;= charWidth;
                    drawn = true;
                    break;
                }
            } catch (e) {
                continue;
            }
        }

        // 第二輪：嘗試 NFKC 正規化版本
        // （處理 CJK 相容漢字，如 U&#43;FA53 → U&#43;798E）
        if (!drawn) {
            const normalized = char.normalize(&#39;NFKC&#39;);
            if (normalized !== char) {
                for (const fontName of fonts) {
                    try {
                        doc.font(fontName).fontSize(fontSize);
                        if (fontHasGlyph(doc, normalized)) {
                            const charWidth = doc.widthOfString(normalized);
                            doc.text(normalized, currentX, y, { 
                                lineBreak: false, continued: false 
                            });
                            currentX &#43;= charWidth;
                            drawn = true;
                            break;
                        }
                    } catch (e) {
                        continue;
                    }
                }
            }
        }

        // 最後手段：用主字型硬畫
        if (!drawn) {
            doc.font(fonts[0]).fontSize(fontSize);
            doc.text(char, currentX, y, { 
                lineBreak: false, continued: false 
            });
            currentX &#43;= fontSize;
        }
    }
}
```

這個實作的邏輯是：

1. **先試原始字元** — 逐個字型檢查 `hasGlyphForCodePoint`，找到就畫
2. **再試 NFKC 正規化** — 如果所有字型都沒有原始碼位的字形，嘗試正規化後的版本（例如 CJK 相容漢字 → 標準漢字）
3. **最後 fallback** — 都不行就用主字型硬畫，至少不會整行消失

## 流程總結

遇到罕用字需要正確顯示時：

```
1. 到全字庫 (cns11643.gov.tw) 查詢該字
2. 記下 CNS 碼和 Unicode PUA 碼位
3. 用 chr() 將 PUA 碼位存入 PostgreSQL
4. 確保 PDF 產生程式有載入全字庫字型（特別是 Plus 補充字型）
5. 使用 hasGlyphForCodePoint 做精確的字型 Fallback
```

## 重點整理

| 觀念 | 說明 |
|------|------|
| PUA 字元 | Unicode 未收錄的字，由全字庫自行分配碼位，只有全字庫字型能顯示 |
| CJK 相容漢字 | Unicode 標準中的異體字，NFKC 會把它轉成「標準」字形 |
| `widthOfString` | PDFKit 中不可靠的字形判斷方式，.notdef 也有寬度 |
| `hasGlyphForCodePoint` | PDFKit (fontkit) 底層 API，精確判斷字型是否有該字形 |
| 全字庫 TW-Kai-Plus | 包含 CNS 第 3 字面以上的字，PUA 罕用字在這裡 |

## 參考資源

- [全字庫 — 中文標準交換碼](https://www.cns11643.gov.tw/)
- [全字庫字型下載（政府開放資料）](https://data.gov.tw/dataset/5961)
- [Unicode CJK 統一漢字區塊列表](https://en.wikipedia.org/wiki/CJK_Unified_Ideographs)
- [PDFKit 官方文件](https://pdfkit.org/)


---

> 作者: luk  
> URL: https://yoru-karu-blog-lalaluk-52581ac5e0cef170a3c8922c19182ecb6f7bd604.gitlab.io/posts/tutorial/cjk-rare-characters-in-practice/  

