罕用字地獄:一個姓名讓整份 Excel 報表錯位的三層追查


客戶回報:某個月的費用清單匯出後,Excel 開檔跳出「部分內容有問題,是否嘗試復原?」。按下復原,檔案打得開,但資料整片錯位——姓名欄出現地址、身分別欄接到別人的資料。

同一份報表,換一個月份匯出就完全正常。換一家機構匯出也正常。

肇事的是一個字。某位個案的姓名裡有一個罕用字。

這篇把這條連鎖從頭到尾拆開。它跨越三層——資料庫存不下、還原機制不完整、檔案格式的長度欄位算錯——每一層都有各自的錯誤表現,而且每一層的「修好了」都不等於問題解決。


第零層:為什麼罕用字這麼難搞

Unicode 的基本多文種平面(BMP,U+0000–U+FFFF)裝得下絕大多數常用漢字。但台灣的戶政姓名會用到 BMP 以外的字——這些落在增補平面(也叫補充平面,U+10000 以上)。

差別在編碼後的長度:

字元範圍UTF-8 位元組數UTF-16 碼元數
BMP 常用漢字(如「唐」U+5510)31
增補平面(如 U+2F842)42

這個「4」和「2」,就是後面所有災難的來源。

醫療、長照、戶政、健保申報這類系統躲不掉:個案的姓名是戶政給的,你不能叫人家改名。


第一層:資料庫根本存不下

專案的資料表是 utf8 編碼。MySQL 的 utf8每字最多 3 bytesutf8mb3——存不下 4-byte 的增補平面字。

(真正能存的是 utf8mb4。但那是十年前建的表,跨機構有數十個資料庫,改字元集不是一句 ALTER TABLE 的事。)

前端送出的姓名進到後端時,4-byte 字被轉成了 **UTF-16 代理對的「字面字串」**存進去。以「唐」的相容字 U+2F842 為例,資料庫裡實際躺著的是這 10 個 ASCII 字元:

ud87eudc42

這不是亂碼,是有結構的:d87edc42 分別是 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 只比對合法代理對範圍(高位 d800dbff、低位 dc00dfff)。不能圖方便寫成 /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: 選項旗標][實際字元資料...]

規格寫明 cchUTF-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、$opt0x0000,長度就該是位元組數,不能無條件右移。所以 $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 共用程式時特別好用——你無法列舉所有使用情境,但你可以宣告並證明「在某個明確範圍內,我的改動什麼都沒改變」。改動的風險論述從「我測了幾個案例都沒事」升級成「不在這個範圍內的輸入才需要討論」。


給讀者的檢查清單

如果你的系統要處理真人姓名,這幾件事現在就可以查:

  1. 資料表字元集是 utf8 還是 utf8mb4 SHOW CREATE TABLE 一看便知。是 utf8 的話,你的罕用字現在一定以某種殘骸形式躺在裡面。
  2. 還原機制是查表還是演算法? 查表的一律會漏,只是還沒漏到你頭上。
  3. 有沒有地方用「字元數」宣告長度、卻用「另一種編碼」寫內容? 這個 pattern 不限於 Excel——固定長度的申報檔、EDI、定長 TXT 全是同一類坑。
  4. 產出 .xls 還是 .xlsx 能換就換。.xlsx 沒有長度宣告欄位,一整類問題直接消失。
  5. 第三方函式庫在 repo 裡有幾份? md5sum 一下。

後記:AI 在這件事上幫得上什麼

老實說,根因判定這一段 AI 沒有直接給答案——關鍵證據是客戶上傳的那個壞檔,要拿十六進位編輯器對著 BIFF8 規格看 SST 的位元組排列。

AI 真正省下時間的是三個地方:

  • 展開連鎖假說:給它「同一份報表換月份就正常」這個線索,它會列出「資料相關而非邏輯相關」的可能性清單,把「某筆資料的某個特徵」推到前面。編碼問題確實在那份清單上。
  • 解釋規格細節:BIFF8 的 cch 定義、代理對的數學、CJK 相容表意文字與 NFC 的關係——這些都是查得到、但要花時間拼起來的知識。
  • 清點影響範圍:「這兩支函式在整個 repo 被誰呼叫」「有幾份 PHPExcel 副本」這種面狀掃描,交給它做又快又完整,而這正是驗證回歸風險最需要的東西。

模式很清楚:AI 負責把知識與範圍攤平,人負責看證據下判斷。


相關文章: