2011年2月9日 星期三

用 Perl 的 regexp 對 Quoted-Printable 字串編碼解碼

古代某些電腦無法處理 8-bit 字元, 只能處理 7-bit 的 ASCII 碼 -- 基本上就是英文、 數字、 鍵盤上的標點符號等等。 當時如果兩部中文電腦要交換資料 (例如收發 e-mail), 就必須先把 8-bit 的中文轉碼變成 7-bit 的 ASCII 字元 (當然, 就會使用更多 bytes), 以免傳遞路徑當中的英文電腦 把多出來的一個 bit 砍掉, 遺失資料。 Quoted-Printable 編碼就是其中一種很簡單的轉碼方式。 本文介紹如何善用 perl 與 od 這兩個簡單工具, 不必寫程式就能將 qp 碼與 8-bit 碼互轉。

(沒有力氣讀完全文的讀者, 請至少要看一下 結論, 欣賞一下 「三句神功」 的成果有多麼精簡。)

首先下: echo '一中街' | od -t x1 輸出應該長這樣:

0000000 e4 b8 80 e4 b8 ad e8 a1 97 0a
0000012

意思是: "一" 的 utf8 碼是 e4 b8 80, "中" 的 utf8 碼是 e4 b8 ad; "街" 的 utf8 碼是 e8 a1 97, 而 0a 則是換列字元的 utf8 碼。 od 指令可以將輸入資料 (任何檔案, 包含各種無法閱讀的圖檔、 影片檔) 的每一個 byte 以十六進位方式印出來。

上述字串的 qp 編碼很簡單: 在每個 byte 的十六進位之前加上一個等號 "=", 並刪除空格, 變成這樣: =e4=b8=80=e4=b8=ad=e8=a1=97=0a。 如果太長, 可以將它拆成好幾列 (但是不得將代表一個 byte 的三個字元拆開), 並且在拆開的地方補上一個等號, 表示下一列還有資料。 例如下面一段文字, 請剪貼到一個文字檔 asimov.txt 裡面:

歷史不斷地教育人類: 拉開時空距離, 往往更能夠清楚地觀察事理。
不過, 人類似乎從來不曾永久地學會這個課程。
-- 艾薩克 艾西莫夫 (科幻小說家)

然後下指令: od -t x1 < zz | perl -pe 's/^.{7}//; s/ /=/g; s/$/=/' > asimov-qp.txt 產生如下的輸出:

=e6=ad=b7=e5=8f=b2=e4=b8=8d=e6=96=b7=e5=9c=b0=e6=
=95=99=e8=82=b2=e4=ba=ba=e9=a1=9e=3a=20=e6=8b=89=
...
=ab=20=28=e7=a7=91=e5=b9=bb=e5=b0=8f=e8=aa=aa=e5=
=ae=b6=29=0a=
=

最後, 用任何文字編輯器進入 asimov-qp.txt, 把最後兩列尾巴的等號拿掉 (檔案最後面只剩下 "=ae=b6=29=0a=" 和空列), 這就是一個 quoted-printable 的字串。

反過來, 如何將 qp 字串解碼呢? 第一步, 先將所有 「以等號結尾」 的每一列, 跟下一列串起來。 用 perl 的 regexp 來寫, 就是 perl -pe 's/=\n$//' ("把一列最尾巴的等號跟換列字元一起刪掉")。 為了週到與保險起見, (1) 不過 "\n" 是 linux 的換列字元; 若是 dos/windows 檔, 則是 "\r\n"。 此外, 為避免誤串, 要加強檢查條件, 只有當 「最後的等號之前, 出現一組三個長得像是 qp 編碼的字元 "等號-十六進位數字-十六進位數字"」 的時候, 才做代換。 當然, 要小心別誤刪這組數字。」 所以完整的寫法是:

perl -pe 's/(=[a-f\d][a-f\d])=\r?\n$/$1/i' < asimov-qp.txt > a1.txt

第二步, 把每一組的等號刪掉, 用後面兩個數字產生一個 8-bit 字元。 Perl 裡面有一個函數 hex() 可以將 "ff" 之類的十六進位字串轉成數字。 另有一個函數 chr(), 接受數字輸入, 然後輸出它所代表的一個 8-bit 字元。 例如 chr(0x41) 傳回 "A", chr (0x5a) 傳回 "Z"。 但是我們正在代換字串, 能呼叫 perl 的函數嗎? 用 s/abc/xyz/ 代換字串時, 在最後加上一個 "e" 選項, 可以叫 perl 別把 xyz 當成字串, 而要把它當成是程式碼來看待, 把它執行完的結果貼上去當成新的字串。 所以這句話的初版是: perl -pe 's/=([a-f\d][a-f\d])/chr(hex($1))/ieg' 不過, 萬一正文裡面有一些字串看起來很像是 qp, 但其實並不是, 就遭糕了。 例如: "ENCODING=BASE64" 裡面的 "=BA" 會被當做是 qp (實際上並不是)。 用一個很簡單/不完整的測試稍微強化一下條件: 只有當這一列連續出現至少二組字串 (共六個字元) 的時候, 才進行轉換。 最終完整的第二句話變成這樣:

perl -pe 'next unless /(=[a-f\d][a-f\d]){2}/i;
s/=([a-f\d][a-f\d])/chr(hex($1))/ieg' < a1.txt > a2.txt

進入第三步之前, 要先解釋一下為什麼寫這篇。 之前為了將手機通訊錄 vcf 檔和試算表認得的 csv 檔互轉, 所以寫了一支小小的 perl 程式 vcf2csv。 Android 手機所匯出的 vcf 檔裡面, 若含中文, 會出現一段 "...ENCODING=QUOTED-PRINTABLE..." 然後就是一串 qp 字串。 先前的版本直接對 qp 字串解碼, 然後把這一串宣告刪掉。 最近重新改寫, 覺得還是保留這一串比較保險。 但也不能直接保留, 而是必須告訴接手的軟體: 現在傳送的已經是未編碼的 8BIT 字串了。 所以第三步的動作是: 「凡是遇到中文列 (內含 "CHARSET=UTF-8") 就改宣告: 編碼已改為 qp」:

perl -pe 's/CHARSET=UTF-8.*?:/CHARSET=UTF-8;ENCODING=8BIT:/;'
< a2.txt > a3.txt

把這三個動作合在一起, 就變成了 qp28bit 這個小小的 shell script。 這是新版的 vcf2csv 當中的第一個小程式。 請試試看用你所熟悉的任何一種語言來寫, 看看需要多少列。 然後, 你就會知道:

  1. 為什麼 perl 語言的發明人 Larry Wall 是我心中的神;
  2. 為什麼很多人喜歡 用 perl 寫詩;
  3. 為什麼學會用 perl 寫程式之後, 你幾乎可以不必寫程式 (這就是 「不戰而屈人之兵」 吧);
  4. 為什麼 我的 regular expressions 講義 要用 perl 做範例;

:-) 就算沒空學完整的 perl 語言, 光是學 regexp, 就已經很厲害了。 Regexp 是一種報酬率很高的學習投資, 請認真考慮吧!

2 則留言:

  1. http://people.ofset.org/~ckhung/p/vcf2csv/index.zh_TW.php
    的連結是壞的

    回覆刪除