文字編碼雜談 (2)

系列文章的第二篇,就來簡單談談所謂的「亂碼」吧。

亂碼與他們的產地

這節的標題是一篇 Facebook 轉貼文的加註,最原始的出處則是一個中國人的推特。雖然那個表格是以 GBK 編碼為主,但除了怎麼解讀亂碼之外,其實還有一個很重要的訊息在裡面。

什麼樣的訊息?我再次引周思博的文章裡的一段來說明吧:

當PC開始賣到美國以外時,各種不同的OEM字元就被憑空創造出來,大家都把上面這128個字元拿來自己用。舉例來說,字元碼130在某些PC上會顯示為é,不過在以色列賣的電腦上就變成希伯來文字母Gimel (ג),所以當美國人把履歷(résumé)寄到以色列就會變成rגsumג

他的這個例子所要表達的就是:同樣是 130 這個碼,在不同的電腦上用不同的字元集解釋就會顯示出不同的字出來。DBCS 編碼也是一樣,一個字元集表示的字串用另一個字元集來解釋就會出現奇怪的表示。這一點在最開始文章的表格裡是以最右欄的產生原因來表示,可以看到表中的狀況都是因為用了錯誤的字元集來解釋字串的關係。這就是我所說的重要訊息:

亂碼的產生都是因為用錯誤的方式去解讀應該用某種方式解讀的位元組的關係。

一個題外話:我們稱做「亂碼」的這個現象,在世界的其他地方是用一個日文詞稱呼:Mojibake「文字化け」,直譯是「文字產生變化」,不過這裡的「化け」更帶有類似於妖怪變形的意思在內 (像是「化け猫」的「化け」)。或許是因為日文環境在 Unicode 之前曾經有兩套相對流行的 DBCS 編碼的關係吧,這種亂碼現象在日文環境之內就已經很常見了,不需要到跨語言交換才會出現。

說到日文編碼,如果曾經在 200x 年左右在網路上下載過東西的人一定遇過很多日本製遊戲的壓縮檔,要用日文地區設定開啟解壓縮程式才不會發生檔名亂碼,然後執行時也需要用日文地區設定開啟,遊戲內的文字才會正常。會需要這麼做的原因就是:遊戲本身是在日文環境製作的,但卻沒有考慮到編碼問題,所以遊戲內的文字及存取檔案的檔名都是以日文編碼儲存,也預設系統會用日文編碼去解讀;這樣的遊戲拿到別的系統上時,當系統不是用日文編碼解讀時就會造成亂碼了。

壓縮檔的狀況也是類似的,但這個狀況還更麻煩:現在流行的壓縮檔格式最初制定的時間都是在 Unicode 普及之前,因此對於檔案名稱的編碼全部都假定是系統編碼,所以就會發生和上一段一樣的問題。這即是使用日文地區設定開啟解壓縮程式才能正確解讀的原因了。這個狀況在各大壓縮檔案格式開始支援 Unicode 之後就慢慢消失,所以現在大概只有當年留下來的檔案才有可能還有這種問題吧。

あいうえお眉幅

上一篇文章除了介紹 DBCS 之外,有一部份其實是在解釋我當年究竟是怎麼猜到那個編碼的。不過猜是一回事,驗證它是不是又是另一回事,畢竟我們人可以試下去之後去「看看」究竟這個試起來對不對,出現的結果是不是對我們有意義--我在嘗試那兩種編碼看到八個一樣的字時也注意到了它們之間似乎是可以連成詞的:連續的兩個數字解出來的字可以連成「搖頭」、「轟動」這樣的詞,更確認了這很大機率就是這些數字的解釋方法。

但若要為機器的判斷演算法加入這方面的資訊,得要到了近年 AI 領域興起才比較有可能達成;二十年前可沒有類神經網路可以讓機器判斷這解碼出來合不合理,所以只能退而求其次,利用傳統密碼學的頻率分析來幫助判斷。周思博提過瀏覽器會為沒有指定編碼的網頁猜一個編碼,這個判斷現在的瀏覽器裡依然存在,只是複雜許多:例如 Google Chrome 曾經使用過 ICU 函式庫中的編碼猜測功能,後來改用 Google 自家的 Compact Encoding Detector 函式庫。這種頻率分析由於方法本身特性的關係幾乎無法對太短的字串使用,因此常常因為這個判定過程猜錯編碼,而使得文字被錯誤的編碼解讀造成亂碼。

上面提到日文環境有著兩套常見的 DBCS 編碼 (EUC-JP 和 Shift-JIS),所以這種問題在日文環境甚至不只在瀏覽器上存在,還包含後端處理的程式也會發生。我曾經在一些日本製的網頁裡看到過一些表單中有著神秘的隱藏欄位寫著例如「あいうえお眉幅」或「美乳」之類的值,原本以為是某種表單完整性檢查的,後來去找了資料1才知道:這是為了讓後端程式不要把所送出的表單文字判斷為 Shift-JIS 編碼用的;當出現這種狀況時,原本是 EUC-JP 的字被後端當成 Shift-JIS 處理,處理完再送回網頁時,又再以 EUC-JP 解釋後端送出來的東西,就造成亂碼了。

使用這些字能解決這個問題的原因就是,它們的 EUC-JP 編碼裡都會有一般 Shift-JIS 不會出現的位元組組合 (「あいうえお」的首位元組是 0xA4,「眉幅」兩字的末位元組是 0xFD,「美乳」兩字則分別有 0xFE 和 0xFD),因此後端程式便不會將表單文字判斷為 Shift-JIS 而是正確的 EUC-JP 了。

IsTextUnicode

同樣的問題在 Unicode 上也是存在的。Unicode 最一開始的目標確實是為了要定一個標準使這世界上各個語言的使用者都能有一套統一的編碼能夠使用,但就如同周思博的文章後半所提到的,Unicode 的 code point 和它們怎麼轉換成位元組儲存是兩回事,也就是說我們又有了另外很多種位元組的解釋法,而它們和原本既存的編碼之間可以不相容--這就會出現像上一篇裡我提過的那個是 AA 還是中文字的判斷問題了。

英文維基百科裡有這麼一個詞條叫 Bush hid the facts,講的並不是什麼布希藏了什麼事實的陰謀論,而是 Windows 內建的記事本的一個編碼判斷錯誤:當你將這句話一共十八個半形字 (不加句點換行) 打入一個新記事本,儲存關閉再開啟時,它會顯示成九個漢字:「」。這是因為,記事本內部是使用一個 Windows API 函數叫 IsTextUnicode 來猜猜看給定的這串位元組有沒有可能是 Unicode,而這十八個位元組的組合若解釋做 UTF-16 Little Endian 時,全部都變成了中文字:

這就使得 IsTextUnicode 判斷「這看起來像中文,所以它應該是 UTF16-LE」,而記事本照實呈現出來而已。這並不只有看起來像中文的狀況會發生:我在 PTT 上就看過兩次同樣這一個問題出現過,第一個 (發問者回應我的回應) 判斷成了印度文,第二個 (發問者我的回應) 判斷成了千分比和萬分比符號。

BOM

這也就是為什麼,周思博的文章後半段會用那麼長一段篇幅介紹 Unicode 的基本觀念以及程式設計者要用什麼方式去正確標記你的文字編碼;連概念上只有文字的「純文字檔案」都要塞一個叫做 BOM 的東西去說「我這文字是使用這種 Unicode 編碼儲存的」,否則:

等到某一天,當他們寫的內容不符合所用語言的字母頻率分佈時,Internet Explorer就會把它認成韓文來顯示。

提到 BOM 就來簡單補充一下關於這個字是怎麼來的好了:這個字 U+FEFF 最一開始的用途是「零寬度不斷行空白」2,是一個除了多一個隱形字在那裡之外對排版什麼的完全沒有影響的「字」,而且將它存成 UTF-16 時兩個位元組是不同的。也是因為這些性質,才有人想到把它放在文字檔的開頭當做類似於辨認檔案格式的 Magic Number,來表示這個 UTF-16 是大頭還是小頭編碼 (這即是它現在的名字的來源:Byte Order Mark,位元組順序記號)。後來 U+FEFF 這個字演變成專門用來做這種標記 (原本的不斷行空白的用途現在給了 U+2060 Word Joiner),所以位元組反過來的 U+FFFE 就被保留,不在這個位置定義字,使得誤判位元組順序的機會降到最低。

對於 UTF-8,或許由於轉換程度沒有特別處理,或許因為 BOM 類似於 Magic Number 的用法而錯誤地認為 UTF-8 也要使用這個字做標記,BOM 字元的 UTF-8 表示 0xEF 0xBB 0xBF 也會出現在很多 UTF-8 編碼的文字檔的開頭 (像是 Windows 內建的記事本存成 UTF-8 時就一定會加上);Unicode 標準允許但不鼓勵,畢竟一來 UTF-8 本來就不需要這樣的標記,二來其位元組組合的設計不容易在其他 DBCS 編碼裡出現,三來有些程式更是需要不加 BOM 才能正確運作。最後這一點最惡名昭彰的例子就是 PHP:如果要使用 UTF-8 做為 PHP 程式的編碼的話,必須不加 BOM 有些功能才能正常運作。這一點甚至到了現在的 PHP 7 仍然如此,所持的理由就是 PHP 只管 <?php ?> 裡面的東西,所以在那外面的 BOM 不歸我管。3

吃掉一半的字

以上談的是由於字碼頁解釋不同造成的亂碼。但其實 DBCS 編碼還有另一種亂碼的成因:兩個一組的位元組有一個因為各種原因不見了。不見的原因有很多:

  • 因為各種原因,讀取的起始點並不是一個字的第一位元組;
  • 字串第一個位元組由於各種操作原因被忽略了;
  • 字串第二個位元組因為衝碼被吃掉了;
  • 等等

這造成的結果就是有一個字的後半位元組會跟下一個字的前半位元組一起解釋成一個「字」,然後這之後的所有位元組的解釋就全部歪掉了,產生沒有意義的字。在 PTT 上不時會看到有些推文的開頭會是奇怪的亂碼,那就是在輸入推文時,字串的第一個位元組因為之前的操作的關係被忽略了,使得輸入框沒有收到那個位元組,因此得到的字串就會出現亂碼了。

舉個實際的例子:如果把大五碼的「批踢踢實業坊」六個字 12 個位元組中的第一個給去掉的話,會發生這樣的錯誤解讀:

變成了「敶蟢薾篞~坊」這樣的亂碼了。可以注意到,大五碼的第二個位元組包含英文區的位元組在這個狀況裡反而是個好處,因為這樣的解讀錯誤會在英文位元組停止,把那第二個位元組解釋成使用一個位元組的英文,然後其後的字就又能正確解讀了。這也就是為什麼在大五碼這種亂碼通常只會亂開頭,會在一個英文或半型符號之後回復正常;其他沒有使用英文區的 EUC 類編碼 (例如 EUC-CN) 就不會有這種修正的機會,一出這種錯就是一整串的中文全部亂掉。

周思博在簡單提到 DBCS 時有說過,DBCS 編碼的文字要往前或往後移一個字並不能直接將指標 +1 或 -1,而要呼叫特別處理的函數,其原因就是為了避免發生這種把字切一半的問題。這種函數會往前找到一個能夠明確判斷這是一個字的第一位元組的地方,然後往後數到目前的位置來決定它附近的位元組組合要怎麼配對,進而去對應地操作字串。在 PTT 這種以大五碼為內碼的 BBS 系統當中這種判斷會需要在輸入文字時不斷進行,才不會一不小心把一個字從中間切斷出現亂碼。早期的 BBS 連線軟體當中因此會有「偵測游標是不是在中文字的前半上,是的話左右移會幫你多移一格」的功能在,幫助沒有把這個功能做在 BBS 系統層的站移動游標更為直覺;不過現在 BBS 幾乎只剩下 PTT 了,而 PTT 也早就把這樣的功能給內建,所以現在都是建議使用者把連線軟體的設定關掉讓 PTT 做處理。

小結

關於亂碼就大致先談到這裡。原本這裡是還想要來詳細解釋最開頭的那張表,以及討論延伸到大五碼的部份的,不過由於細節非常多,寫起來大概會是百科全書等級的整理分析,再加上現在 Unicode 普及程度比起十幾二十年前已經好很多了,這樣的整理只有萬一碰到了才會需要參考,所以可能要等我非常閒的時候才會做吧。

註腳

  1. 還是很近期的後來:是我大約一年多前因為好奇心去搜這個字串才搜到這個老網頁講到原因的。這兩段的解釋幾乎都是引用自這個地方。 ↩︎
  2. Zero-Width No-Break SPace,簡稱 ZWNBSP;前面那個名字現在仍然是 U+FEFF 的正式 Unicode 名字,因為為了維持字碼資料庫的向前相容性,一個 Unicode 字的正式名字一旦定名了就無法改變。 ↩︎
  3. 其他還有比較小條的理由,例如若 PHP 產生的文件真的需要 BOM 則需要保持能夠產生的方式等等,但最大的理由真的就是那不歸我管所以我不想管…… ↩︎

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料