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

從 Unicode PUA 到 PDF 字型 Fallback 的完整解決方案

前言

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

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

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

先備知識:Unicode 到底是什麼?

電腦只認數字

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

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

從 ASCII 到 Unicode

最早美國人制定了 ASCII,只有 128 個位置,連英文+數字+符號就用完了,中文完全沒位置。

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

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

U+0041 = A
U+4E2D = 中
U+798E = 禎

Unicode ≠ UTF-8

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

  • Unicode = 編號表(「禎」的編號是 798E)
  • UTF-8 = 儲存方式(798E 這個編號在硬碟上要用幾個 byte 存?怎麼存?)
「A」= U+0041
  UTF-8 儲存:41                (1 byte)

「中」= U+4E2D  
  UTF-8 儲存:E4 B8 AD          (3 bytes)

 PUA 罕用字 = U+FBF34
  UTF-8 儲存:F3 BB BC B4       (4 bytes)

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

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

把 Unicode 想成一棟大樓,不同樓層住著不同的字:

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

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

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

問題背景

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

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

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

罕用字住在哪裡?

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

Unicode 碼位住在哪層說明
禎(礻旁)U+798E0 樓,常用漢字區所有字型都有
禎(示旁,相容版)U+FA530 樓,CJK 相容漢字區異體字,NFKC 會轉成 U+798E
禎(示旁,全字庫版)U+FBF3415 樓,PUA 造字區只有全字庫字型認得

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

CNS 碼:13-7275
Unicode PUA:U+FBF34(十進位 1031988)

實際遇到的兩種情況

情況一:CJK 相容漢字(U+FA53)

資料庫中存的碼位是 U+FA53,屬於 CJK Compatibility Ideographs。這類碼位的特殊之處在於,當你對它做 NFKC 正規化(Normalization Form KC)時,會被自動轉換成標準碼位:

// U+FA53 (示旁的禎) → NFKC → U+798E (礻旁的禎)
const original = '\uFA53';
const normalized = original.normalize('NFKC');
console.log(original === normalized); // false — 字被換掉了!

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

情況二:PUA 字元(U+FBF34)

另一個學生的名字包含全字庫自定義的 PUA 字元 U+FBF34。這類字的特性是:

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

解決方案

1. 資料庫儲存

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

-- U+FBF34 = 十進位 1031988
UPDATE signup_info 
SET name = '郭于' || 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 ✓

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

2. 字型準備

台灣的全字庫提供了完整的字型檔,以楷體為例:

字型檔涵蓋範圍
TW-Kai-98_1.ttfCNS 第 1-2 字面(常用字)
TW-Kai-Plus-98_1.ttfCNS 第 3 字面以上(含 PUA 罕用字)
TW-Kai-Ext-B-98_1.ttfCJK Extension B

關鍵:PUA 字元(如 U+FBF34)在 TW-Kai-Plus 裡面,不是在主字型 TW-Kai 裡。

3. PDFKit 字型 Fallback 機制

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

踩過的坑:widthOfString 不可靠

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

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

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

正確做法:hasGlyphForCodePoint

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

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

完整的 Fallback 實作

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

    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 += charWidth;
                    drawn = true;
                    break;
                }
            } catch (e) {
                continue;
            }
        }

        // 第二輪:嘗試 NFKC 正規化版本
        // (處理 CJK 相容漢字,如 U+FA53 → U+798E)
        if (!drawn) {
            const normalized = char.normalize('NFKC');
            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 += 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 += 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 會把它轉成「標準」字形
widthOfStringPDFKit 中不可靠的字形判斷方式,.notdef 也有寬度
hasGlyphForCodePointPDFKit (fontkit) 底層 API,精確判斷字型是否有該字形
全字庫 TW-Kai-Plus包含 CNS 第 3 字面以上的字,PUA 罕用字在這裡

參考資源

0%