Ruby 與字符編碼

Posted by Noel on 2018-02-04

前言:

編碼一直是個有趣和容易讓人似懂非懂的議題,我們多少總有碰過亂碼的檔案,或是需要在不同編碼中轉或語系的需求;也許只需要去 Google 一下方法,或是藉由 Iconv 來做轉碼,但是對於亂碼的問題,或是編碼的含義依然一半解,又或著過了一陣子之後又得重新再 Google 找解法。與其不斷地盲目亂找治標不治本的答案或解法,倒不如真正有系統地去探索問題背後的緣由或脈絡,真正地去解決問題。

何謂編碼 (Encode)?

雖然我們無論是在寫程式或是在處理文件都是輸入英文、甚至中文、各種標點符號等等,但實際上電腦是無法認得這些符號的,所以勢必會將這些文字或符號轉化成數字,而且是電腦認得懂得數字格式。

ruby 版本

1
2
'Ruby is awesome'.bytes
# [82, 117, 98, 121, 32, 105, 115, 32, 97, 119, 101, 115, 111, 109, 101]

python 版本

1
2
list('Ruby is awesome'.encode('utf-8'))
# [82, 117, 98, 121, 32, 105, 115, 32, 97, 119, 101, 115, 111, 109, 101]

我們得到一堆數字,而這些數字就是 Ruby is awesome 的編碼所得到的數字,也就是說:

編碼就是定義了字串如何對應到數字間的關係

而對應不同的編碼方式也就會得到不同的編碼

1
2
3
4
5
# 預設是 utf-8
'Ruby 很有趣'.bytes
# [82, 117, 98, 121, 32, 229, 190, 136, 230, 156, 137, 232, 182, 163]
'Ruby 很有趣'.encode('big5').bytes
# [82, 117, 98, 121, 32, 171, 220, 166, 179, 189, 236]

故,即使是相同的文字藉由不同的編碼方式就會得到不同的數字結果,也就是說當如果當如果得到與作業系統預設不同編碼格式的檔案,就是必得做一層編碼切換才可以取得正確地結果。

錯誤的編碼

以上為例,如果硬拿 big5 的編碼格式直接去取得對照 utf-8 的文字,因兩者的編碼格式不同,不同數字參照的文字當然也就不同,所以會取得錯誤的結果,甚至是得到無效的結果,也就是所謂的亂碼;常見的亂碼可能:

  1. 用與原始檔案不同的編碼去解碼,而得到錯誤的結果(拿明朝的劍去斬清朝的官)
  2. 承接 1 的結果,有可能會是可以解碼但結果無意義、或著是連解碼都失敗,因為有無效字元
  3. 相同語系的編碼,但其中一方的字集較少,轉換的時候會出現遺失(例如:『裏』在 utf-8 有收錄,而 big5 沒有)
  4. 檔案本身記錄的編碼格式是錯誤的,一開始就被騙了

其中 3 跟 4 都是屬於比較難處理的部份, 3 可能得自己造字,4 的話只能使用經驗法則去抽絲剝繭外加通靈了

編碼的演化

因為電腦最早是由美國人發明的,所以最早支援的也就是只有英文字母、數字、幾個常見的標點符號,例如:

ASCII (American Standard Code for Information Interchange)

1968 第一次發表,最早使用一個字節,即 1 byte = 8 bits,然後最前面的
bit 為 0 ,因此僅使用後面 7 個 bit 表示 128 種字元(含英文大小寫、數字、基本的符號),而在之後又有在經過一些拓展

但隨著電腦的普及,以及越來越多國家有自己的文字或符號想做表示,所以 ASCII 自然無法勝任,而各國也開始發展自己的字符編碼系統,以台灣為例, Big5 就是支援最早支援繁體中文的主流,收錄了 13xxx 個繁體中文字,而當然日本、韓國、中國、世界各地都有建置自己的符合自己需求的編碼系統,因此編碼的種類已經逐繁不及備載,而且也衍生新的議題:

不同編碼轉換的需求

例如,如果我得作業系統內建只有支援 Big5 那如果我有一個檔案是 SHIFT-JIS (日文編碼),我的作業系統將無法正確讀取此檔案內容,所以就得去再下載該編碼來顯示,或是去尋找能同時支援中、日語系的編碼並且將之轉化;但如果我下次想開啟韓文或是法文的編碼檔案,那我就得再尋找同時支援這幾種語系的編碼。

於是 Unicode 就誕生了

Unicode

Unicode 就是萬國碼,簡單的說就是一個包含目前所有常見語系編碼、與符號的超大字集表(目前有收錄一百多萬個;理想而言,大家都使用這個超大的字符集,應該就不會有轉換編碼的需求,但又出現另一個難題,既然有至少一百多萬個字元、符號(而且未來可能還會更多),那就需要更多個字節 (byte) 去儲存他,這種情況對中文可能還好(因為中文本來就需要 2 或更多個字節去儲存),但對英文就顯得非常浪費了,因為英文本來就只需要用一個字節去儲存,但使用了 Unicode 後可能被迫使用 4 個字節去儲存它。

因此儲存 Unicode 顯然是一種空間浪費。

UTF-8

UTF-8 就是為了解決這個問題而誕生的,UTF-8 就是一種 Unicode 的 表現方式 ,而且他的字節數是可變動的,例如當碰到數字或英文時它會使用一個字節去儲存,如果碰到中文或其他的符號,它會使用多個字節去儲存,從而達到支援多語系轉換,又不浪費儲存空間的特性,而且另一個特性是,因為使用一個字節來儲存英文與數字,所以當內文只含數字與英文時,又可以與 ASCII 完美契合。

UTF-8 如何達到這種神奇的方式呢?它如何讓系統知道哪幾個字節要一起使用,哪幾個不用呢?這就在於他的轉換原理:

  1. 如果是英文或數字,那僅佔用一個字節,而此時此字節的二進位的表示與 ASCII 一致
  2. 當如果是需要多個字節表示時,它有特定的規則去組合
    先觀察第一個字節開頭有幾個 1 就表示 要幾個字節 當做一個字看,而後面會接一個 0 後再接續剩下的編碼加上之後每個字節去掉開頭是 10 的部份就是最後的 Unicode 編碼

‘1110xxxx 10xxxxxx 10xxxxxx’ => 表示三個字節

1
2
3
4
"陳".bytes.map { |c| '%08d' % c.to_s(2) }
=> ["11101001", "10011001", "10110011"]
"1001011001110011".to_i(2).to_s(16)
=> "9673" # \u9673 就是 '陳' 的 unicode

因此,我們得知 UTF-8 是一種 Unicode 的 表現格式,但又較能 節省空間,所以也目前最被廣泛使用與支援的檔案格式。

當不知道要使用什麼編碼的話就用 UTF-8 吧

Unicode 與編碼的關係

目前大部分的系統都已內建與支援 Unicode ,當檔案已讀到到記憶體時,便是以 Unicode 的形式存在,而當需要寫入到檔案、DB,或是要藉由網路傳輸時,會再由 Unicode 轉換成編碼格式後的數據寫入或傳送(省空間)。

除此之外,不同語系編碼的轉換也有賴於 Unicode 的存在;我們已經知道 Unicode 已經涵蓋大多主流的語系,所以我們也可以藉由 Unicode 來轉換語系;而此時我們需借用 代碼頁 (codepage) 來標示不同編碼與 Unicode 的映射關係,並進行轉換。而我們最常使用的工具就是 Iconv 來幫助我們做到這一塊,而它的背後也是需要借助 Unicode 來完成的。(詳盡的轉換細節我並未深入,所以在此只能講個粗略的原理)。

Ruby Encode

花了很長的篇幅粗略交代與介紹編碼的由來,這樣大家對編碼的使用與操作也比較有些概念與認識,也比較容易切入之後的介紹。

在 Ruby 裡我們可以直接使用 encoding 來查看目前的編碼

1
2
3
str = 'Ruby 就是潮!'
str.encoding
#<Encoding:UTF-8>

並且使用 encode 來切換編碼

1
2
str.encode('big5')
=> "Ruby \x{B44E}\x{AC4F}\x{BCE9}!"

不是所有的語系都可以隨意轉換

1
2
str.encode('windows-1252')
=> Encoding::UndefinedConversionError: U+5C31 to WINDOWS-1252

但可以利用 invalid, undef 來跳脫無效或無法定義的字元

1
2
str.encode("Windows-1252", invalid: :replace, undef: :replace)
=> "Ruby ???!"

不過此舉雖然可以成功 encode ,但基本上會失去不少資訊,所以幫助也不是很大

還有個方法 force_encoding 可以幫助我們強行把編碼轉換並且 顯示 出來

1
2
str.force_encoding('big5')
=> "Ruby \x{E5B0}\x{B1E6}\x98\x{AFE6}\x{BDAE}!"

修正錯誤編碼

這大概是最重要又最難的一個章節,如 Justin Weiss 的建議,當碰到錯誤編碼的時候,可用三個步驟來修正問題:

1. 找出現在字串的 真正編碼

雖然我們可以透過 encoding 來找出編碼,但實際上有可能依然是錯誤的

1
2
3
str = '100073511445||¥_¥x¤¤Àç·~©Ò|20171103065705|2497319201|00006|°t°e¤¤|'.encoding'
str.encoding
=> #<Encoding:UTF-8>

UTF-8 !!!? 四眼田雞你唬我啊,因為 伴隨著奇怪的符號,我們可以猜測應該不是 UTF-8,接下來就需要靠一些經驗法則外加通靈來尋找了。

由於一些老舊軟體綁定設定到某些特定的編碼,所以我們可以反向猜測該軟體或系統的編碼來做推敲,例如還是很多公司行號或公家機關使用 Windows 的系統,又如果是早期像是 XP 或是更早的 98 等等,可能就會有比較頑固的老編碼問題存在。

Did someone paste it in from Word? It could be Windows-1252. Did it come from a file or did you pull it from an older website? It might be ISO-8859-1.

Justin Weiss

如果還是不行,只能 從特殊字元去反搜 哪些編碼會使用到,再去做塞選,此時可以搭配一些線上工具 傳送門 幫助做人工驗證,而因為我知道這個編碼預期會是以中文的模式顯示,所以在交叉試驗後我得知:

此字串以 Windows-1252 做 encode,以 Big5 做 deconde 時可以正確呈現

2. 決定要讓哪種編碼方式來使用

為了一勞永逸、為他人著想,以及方便日後在不同的作業系統或是語系做編輯,都推薦使用 UTF-8 就對了。(當然,如果你有很特定的需求也可以使用你想用的)

3. 重複步驟 1步驟 2 直到成功

encode 的第二個參數可以是你想 從哪個編碼過來 / source,所以 encode(target, source) 一般而言就是你要的結果。

1
2
str.encode('utf-8', 'big5')
=> Encoding::InvalidByteSequenceError: "\xC3" followed by "\x80" on Big5

Oops 還是不行,因為我這個例子比較怪異,記得我步驟 1 以提到,要正確呈現必須是 encode with Windows-1252, and decode with Big5,所以必須經由兩次 encode 才可以成功,修正結果如下:

1
2
str.encode('windows-1252','utf-8').encode('utf-8', 'big5')
=> "100073511445||北台中營業所|20171103065705|2497319201|00006|配送中|"

其實就是要先讓 假的 UTF-8 變成 Windows-1252,再由 Big5 讓他可以成功 decode,最後再把這個正確的結果 encode 為 UTF-8 就大功告成了。

結論

編碼的學問很大,而且當碰到錯問題的時候也真的是可以搞死人,所以對編碼有著正確地認識與了解,可以先幫助我們有個脈絡,再藉由其他線上工具以及經驗法則,我們將可以解決絕大部分的問題,並且把這個知識系統化、傳承下去。

參考

3 Steps to Fix Encoding Problems in Ruby