CJK 生僻字實戰:當系統印不出學生的名字
從 Unicode PUA 到 PDF 字型 Fallback 的完整解決方案
前言
你有遇過系統印出來的名字變成方框「□」的情況嗎?
最近在維護一個考試報名系統時,遇到了一個真實的問題:學生的姓名中有「生僻字」,也就是一般字型檔裡沒有的罕用漢字。這些字在資料庫裡存得好好的,螢幕上有的字型能顯示、有的不行,但最關鍵的是——PDF 證書和收據上印不出來。
這篇文章記錄了整個排查和解決的過程,涉及 Unicode 的 Private Use Area、全字庫字型、PDFKit 的字型 Fallback 機制,以及 PostgreSQL 的字元儲存。
先備知識:Unicode 到底是什麼?
電腦只認數字
電腦底層只懂 0 和 1,所以要顯示文字,需要兩件事:
- 編號表:規定「哪個數字 = 哪個字」
- 字型檔:規定「這個數字畫出來長什麼樣子」
從 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+798E | 0 樓,常用漢字區 | 所有字型都有 |
| 禎(示旁,相容版) | U+FA53 | 0 樓,CJK 相容漢字區 | 異體字,NFKC 會轉成 U+798E |
| 禎(示旁,全字庫版) | U+FBF34 | 15 樓,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.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+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;
}
}
}這個實作的邏輯是:
- 先試原始字元 — 逐個字型檢查
hasGlyphForCodePoint,找到就畫 - 再試 NFKC 正規化 — 如果所有字型都沒有原始碼位的字形,嘗試正規化後的版本(例如 CJK 相容漢字 → 標準漢字)
- 最後 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 罕用字在這裡 |