客戶回報:某個月的費用清單匯出後,Excel 開檔跳出「部分內容有問題,是否嘗試復原?」。按下復原,檔案打得開,但資料整片錯位——姓名欄出現地址、身分別欄接到別人的資料。
同一份報表,換一個月份匯出就完全正常。換一家機構匯出也正常。
肇事的是一個字。某位個案的姓名裡有一個罕用字。
這篇把這條連鎖從頭到尾拆開。它跨越三層——資料庫存不下、還原機制不完整、檔案格式的長度欄位算錯——每一層都有各自的錯誤表現,而且每一層的「修好了」都不等於問題解決。
第零層:為什麼罕用字這麼難搞
Unicode 的基本多文種平面(BMP,U+0000–U+FFFF)裝得下絕大多數常用漢字。但台灣的戶政姓名會用到 BMP 以外的字——這些落在增補平面(也叫補充平面,U+10000 以上)。
差別在編碼後的長度:
| 字元範圍 | UTF-8 位元組數 | UTF-16 碼元數 |
|---|---|---|
| BMP 常用漢字(如「唐」U+5510) | 3 | 1 |
| 增補平面(如 U+2F842) | 4 | 2 |
這個「4」和「2」,就是後面所有災難的來源。
醫療、長照、戶政、健保申報這類系統躲不掉:個案的姓名是戶政給的,你不能叫人家改名。
第一層:資料庫根本存不下
專案的資料表是 utf8 編碼。MySQL 的 utf8 是每字最多 3 bytes 的 utf8mb3——存不下 4-byte 的增補平面字。
(真正能存的是 utf8mb4。但那是十年前建的表,跨機構有數十個資料庫,改字元集不是一句 ALTER TABLE 的事。)
前端送出的姓名進到後端時,4-byte 字被轉成了 **UTF-16 代理對的「字面字串」**存進去。以「唐」的相容字 U+2F842 為例,資料庫裡實際躺著的是這 10 個 ASCII 字元:
ud87eudc42
這不是亂碼,是有結構的:d87e 和 dc42 分別是 U+2F842 的高位與低位代理(surrogate),前面各加一個字面的 u。這是 JavaScript \uXXXX 逃逸序列被剝掉反斜線後的殘骸。
所以資料庫裡的姓名是壞的,而且是十年來一直壞的。頁面上看起來正常,是因為輸出時有一層還原。
第二層:還原靠硬編碼白名單
既有的還原函式 special_decode_string() 是查表的:維護一份大約 30 個字的對照表,命中就換回真字,沒命中就原樣輸出。
於是使用者看到的畫面是這樣:
- 名單裡的罕用字 → 正常顯示
- 名單外的罕用字 → 螢幕上直接出現
ud87eudc42
這是我接到的第一張單:個案名冊與申報檔匯出,姓名變成 ud87eudc42 亂碼。
修法:用演算法還原,不要維護清單
代理對的還原是純數學,沒有理由查表:
<?php
/*******************************************
* 罕用字還原(報表匯出用)
*
* 增補平面(非 BMP)字元的 UTF-8 編碼為 4 bytes,無法存入 utf8 欄位,
* 資料庫改以 UTF-16 代理對的「字面字串」存放,例如 U+2F842 存成 ud87eudc42。
*
* 既有的 special_decode_string() 靠硬編碼對照表還原,未收錄的字就會
* 原樣輸出成 ud87eudc42 這種亂碼。此處改以演算法通用還原,
* 不必逐字維護清單。
*******************************************/
if (!function_exists('rarechar_decode')) {
function rarechar_decode($data)
{
if (!is_string($data) || $data === '') { return $data; }
// 僅比對合法的 UTF-16 代理對:高位 d800-dbff、低位 dc00-dfff
return preg_replace_callback(
'/u(d[89ab][0-9a-f]{2})u(d[c-f][0-9a-f]{2})/i',
function ($m) {
$cp = 0x10000 + ((hexdec($m[1]) - 0xD800) << 10) + (hexdec($m[2]) - 0xDC00);
$ch = mb_chr($cp, 'UTF-8');
// CJK 相容表意文字(如 U+2F842)多數字型沒有字形,會顯示成方框,
// 以 NFC 正規化轉為標準字(U+2F842 → U+5510 唐)。
// 真正的罕用字(如 CJK 擴充 B 的 𡍼)無正規化對應,會維持原樣。
if (class_exists('Normalizer')) {
$normalized = Normalizer::normalize($ch, Normalizer::FORM_C);
if (is_string($normalized) && $normalized !== '') { $ch = $normalized; }
}
return $ch;
},
$data
);
}
}
有兩個細節值得說。
一、regex 只比對合法代理對範圍(高位 d800–dbff、低位 dc00–dfff)。不能圖方便寫成 /u([0-9a-f]{4})u([0-9a-f]{4})/——姓名以外的欄位可能出現看起來像、實際不是代理對的字串,誤轉就是製造新的亂碼。
二、NFC 正規化這一步是關鍵,而且它只該對某一類字生效。
增補平面裡混著兩種東西:
- CJK 相容表意文字(U+2F800–U+2FA1F):這些是為了跟舊標準往返轉換而存在的重複字。U+2F842 和 U+5510「唐」是同一個字,只是碼位不同。多數字型沒有這些碼位的字形,直接顯示會變成方框「□」。NFC 正規化會把它們轉成標準字。
- 真正的罕用字(如 CJK 擴充 B 的 𡍼 U+2137C):這是獨立的字,沒有正規化對應,
Normalizer::normalize()會原樣回傳。
所以同一行 Normalizer::normalize() 同時做了兩件事:把「假罕用字」還原成看得見的常用字,把「真罕用字」原封不動放行。不需要分類判斷,正規化表本身就是那份分類。
套用範圍刻意壓到最小
include('class/DBPDO.php');
include('class/array.php');
include('class/function.php');
+ include('class/function.rarechar.php');
$tmp = [
'身分證字號' => $patIdNo,
- '姓名' => special_PHPExcel_string($data[$i]['patient_name']),
+ '姓名' => special_PHPExcel_string(rarechar_decode($data[$i]['patient_name'])),
只在兩支匯出檔的姓名欄加一層函式,不動任何共用類別。理由很直接:special_decode_string() 被上百支程式呼叫,改它等於把整個系統的字串處理行為換掉,收益完全不值得那個風險。
到這裡,畫面上不再有 ud87eudc42 了。
然後 Excel 開始壞掉。
第三層:還原成功之後,檔案反而毀了
還原前,罕用字是 ASCII 字串 ud87eudc42——對 Excel 產檔器來說就是一串普通英數字,毫無威脅。
還原後,它變成貨真價實的 4-byte 字元。而 PHPExcel 的 .xls(BIFF8)產檔器處理不了。
根因:cch 是「UTF-16 碼元數」,不是「字元數」
BIFF8 的字串記錄格式長這樣:
[cch: 字串長度][opt: 選項旗標][實際字元資料...]
規格寫明 cch 是 UTF-16 碼元數。而 PHPExcel 的實作是:
// 原本的寫法
$ln = self::CountCharacters($value, 'UTF-8'); // 內部是 mb_strlen → 取「字元數」
// ...
$data .= self::ConvertEncoding($value, 'UTF-16LE', 'UTF-8'); // 實際寫出 UTF-16LE
宣告長度用「字元數」,實際內容寫「UTF-16LE 位元組」。
對 BMP 字元,兩者一致——1 個字元 = 1 個碼元。所以這個 bug 藏了十幾年沒人發現。
碰到增補平面字,1 個字元 = 2 個碼元,宣告值就比實際少 1。
為什麼「少 1」會讓整份報表錯位
因為 .xls 有共用字串表(SST,Shared String Table):所有儲存格的文字集中存在一張表裡,儲存格本身只放索引。
SST 是連續緊密排列的——Excel 讀完第 N 個字串,就從「宣告的長度」算出下一個字串從哪裡開始。
一旦某個字串的宣告長度少 1,Excel 的讀取位置就偏移一個碼元,從那個字串之後的所有內容全部對不上。姓名讀到一半、地址接在後面、身分別接到別人的資料上。
這解釋了所有觀察到的現象:
| 觀察 | 原因 |
|---|---|
| 換個月份匯出就正常 | 那個月剛好沒有罕用字個案 |
| 是「錯位」而不是「單一儲存格亂碼」 | SST 是連續結構,錯一個就從此全歪 |
| Excel 說「部分內容有問題」而非拒絕開啟 | 檔案結構完好,只是內容對不齊 |
| 只有某幾家機構會遇到 | 個案姓名的字元分布不同 |
三種解法,三種取捨
這條連鎖在專案裡先後出現三次,被用三種不同方式處理過。三種都是合理的工程決定,差別在當下的風險預算。
解法 A:把罕用字換成「□」(迴避)
最早的做法。special_PHPExcel_string() 用一份 30 字白名單,命中的換成常用字,其餘的直接替換成方框。
- ✅ 檔案不會壞
- ❌ 使用者看到「王□明」,申報檔送出去會被退件
- ❌ 白名單要人工維護,永遠追不上
專案裡至今仍有十幾支報表停在這個狀態。
解法 B:繞過 PHPExcel,自己寫 xlsx 產檔器(繞過)
有一支匯出量特別大的報表,同時遇到檔案損毀與記憶體問題。當時的處理是新增一支單檔、零第三方相依的 .xlsx 產檔器,只用 ZipArchive 串流寫出。
關鍵在於格式選擇本身就消滅了問題:
.xlsx是 XML/UTF-8、沒有長度宣告欄位,架構上不存在上述問題,罕用字直接顯示原字。
儲存格用 inlineStr、工作表 XML 邊組邊寫暫存檔,記憶體用量與資料量無關。跳脫函式另外處理了無效/截斷的 UTF-8、C0 控制字元、U+FFFE/U+FFFF、單格 32767 字上限。
實測結果:產檔 2.66 秒/0.62MB,9,841 列逐欄與原頁面渲染邏輯比對全數相同。
- ✅ 徹底解決,順便解決記憶體
- ✅ 影響範圍精確可控(只有那一支報表改端點)
- ❌ 自己維護一支產檔器
- ❌ 只救到這一支,其他 50 支還在原地
解法 C:直接修 PHPExcel 本體(治本)
最後有人做了正確的事——改長度計算,讓宣告值由實際寫出的位元組數推算:
public static function UTF8toBIFF8UnicodeShort($value, $arrcRuns = array())
{
// BIFF8 的 cch 是「UTF-16 碼元數」不是「字元數」。
// 原本用 CountCharacters()(mb_strlen),補充平面(4-byte)字佔 2 個碼元卻只算 1,
// 宣告值每字少 1 → SST 整段錯位,Excel 判定「部分內容有問題」。
// 改由實際寫出的位元組數推算,宣告值與內容必定一致。
// 註:iconv/mbstring 都不可用時 ConvertEncoding() 不做轉換、$opt 亦為 0x0000(壓縮 8-bit),
// 此時 cch 仍是位元組數,不可 >>1,故與 $opt 綁在一起判斷。
$isUtf16 = (self::getIsIconvEnabled() || self::getIsMbstringEnabled());
// characters
$chars = self::ConvertEncoding($value, 'UTF-16LE', 'UTF-8');
// character count
$ln = $isUtf16 ? (strlen($chars) >> 1) : strlen($chars);
if (empty($arrcRuns)) {
$opt = $isUtf16 ? 0x0001 : 0x0000;
$data = pack('CC', $ln, $opt);
$data .= $chars;
} else {
$data = pack('vC', $ln, 0x09);
$data .= pack('v', count($arrcRuns));
$data .= $chars;
// ...
}
return $data;
}
核心是一句話:不要「算」長度,要「量」你真正寫出去的東西。
先產出 $chars,再從 strlen($chars) 反推。宣告值與內容從此不可能不一致——這是把「兩處各自計算、必須保持同步」改成「一處計算、另一處衍生」的典型修法。
那個 $opt 的細節也值得注意:iconv 與 mbstring 都不可用時,ConvertEncoding() 不做轉換,此時內容是壓縮的 8-bit、$opt 為 0x0000,長度就該是位元組數,不能無條件右移。所以 $ln 與 $opt 必須綁在同一個判斷裡。原本的程式碼把這兩件事分開寫在不同位置,正是這種 bug 的溫床。
一個容易漏掉的坑:函式庫有三份副本
修 PHPExcel 時發現,repo 裡有三份同源的 PHPExcel:
module_a/export/tools/PHPExcel/Shared/String.php
module_a/lib/PHPExcel/Shared/String.php
module_service/lib/PHPExcel/Shared/String.php
修正前三份的 md5 完全相同——是同一份被複製了三次。只改一份,另外兩份負責的報表照壞。其中一份被 51 支 .xls 匯出共用,其中 36 支連白名單防護都沒有。
驗證副本是否同源,最快的方法:
md5sum module_a/export/tools/PHPExcel/Shared/String.php \
module_a/lib/PHPExcel/Shared/String.php \
module_service/lib/PHPExcel/Shared/String.php
Legacy 專案在 Composer 普及前,vendor 目錄常常是手動複製進去的。改任何第三方函式庫之前,先確認它在 repo 裡有幾份。
怎麼證明沒有回歸
改別人的函式庫是高風險動作。這次的驗證方式很值得抄。
一、確認呼叫範圍。 這兩支函式在函式庫內只被 Writer/Excel5/* 使用(實際清點:17 處呼叫全在該目錄),所以不影響讀取器、Excel 匯入與 .xlsx 產出。
二、對不含罕用字的資料做 byte-level 比對。
不含補充平面字的資料完全不受影響:無罕用字租戶產檔的 Workbook 資料流修正前後 byte diff 為 0。
這是我認為最漂亮的一步。與其寫一堆「應該還是對的」測試,不如直接主張一件可驗證的事實:對絕大多數資料,這個修改的輸出必須逐位元組相同。
這種「不變性驗證」在改 Legacy 共用程式時特別好用——你無法列舉所有使用情境,但你可以宣告並證明「在某個明確範圍內,我的改動什麼都沒改變」。改動的風險論述從「我測了幾個案例都沒事」升級成「不在這個範圍內的輸入才需要討論」。
給讀者的檢查清單
如果你的系統要處理真人姓名,這幾件事現在就可以查:
- 資料表字元集是
utf8還是utf8mb4?SHOW CREATE TABLE一看便知。是utf8的話,你的罕用字現在一定以某種殘骸形式躺在裡面。 - 還原機制是查表還是演算法? 查表的一律會漏,只是還沒漏到你頭上。
- 有沒有地方用「字元數」宣告長度、卻用「另一種編碼」寫內容? 這個 pattern 不限於 Excel——固定長度的申報檔、EDI、定長 TXT 全是同一類坑。
- 產出
.xls還是.xlsx? 能換就換。.xlsx沒有長度宣告欄位,一整類問題直接消失。 - 第三方函式庫在 repo 裡有幾份?
md5sum一下。
後記:AI 在這件事上幫得上什麼
老實說,根因判定這一段 AI 沒有直接給答案——關鍵證據是客戶上傳的那個壞檔,要拿十六進位編輯器對著 BIFF8 規格看 SST 的位元組排列。
AI 真正省下時間的是三個地方:
- 展開連鎖假說:給它「同一份報表換月份就正常」這個線索,它會列出「資料相關而非邏輯相關」的可能性清單,把「某筆資料的某個特徵」推到前面。編碼問題確實在那份清單上。
- 解釋規格細節:BIFF8 的
cch定義、代理對的數學、CJK 相容表意文字與 NFC 的關係——這些都是查得到、但要花時間拼起來的知識。 - 清點影響範圍:「這兩支函式在整個 repo 被誰呼叫」「有幾份 PHPExcel 副本」這種面狀掃描,交給它做又快又完整,而這正是驗證回歸風險最需要的東西。
模式很清楚:AI 負責把知識與範圍攤平,人負責看證據下判斷。
相關文章: