2018年7月14日 星期六

TStrings, TStringList, UTF8 與 BOM

在 Delphi 2009 推出以前的年代,要處理 Unicode 的資料只能透過 tntWare 的元件,但在 Delphi 2009 之後,從 VCL 到 RTL 全部都已經相容於 Unicode 了,甚至連變數跟 Class 的名字都可以用 Unicode (你喜歡繁中簡中日文夾雜也可以) 來命名,所以程式內部對於 Unicode 的顯示、處理完全沒有問題。

但Unicode 的影響真的是深入到每個細縫,否則我也不會跟它奮鬥了將近20年......

文字檔的處理

對於 Delphi 的資深使用者來講,尤其是越資深的程式人員越有可能使用了這樣的 Work around,也就是把文字檔當成資料庫來使用。

在沒有 SQLite 的 2000 前後,我們可以用 DBase, MDB (Access 的檔案格式),但這些都需要透過驅動程式或者 BDE 來處理。因此有更多的前輩使用的是固定長度文字作為模擬資料庫來使用,這是一把兩面刃,他處理問題、解決問題很快,但製造出來的問題更深更遠,尤其是歷史久遠的程式碼。

在 Delphi 3 到 Delphi 7 的年代,以及一直沒有升級到 Delphi 2009之後版本的前輩,除了會使用固定長度文字作為資料儲存之用,還有一個很糟糕的現象,很普遍,也很嚴重,就是會把 PChar 當成指標,透過 AssignFile 對檔案進行開啟與讀取,然後用 PChar 對檔案內容做 offset 的位置指定與讀取。

乍聽之下這好像沒有任何問題,但是這個『沒有問題』也僅限於 ASCII 與 Single Byte Char的文字檔,也就是在 Unicode 出現之前的年代。

在沒有 Unicode 的時候,全球的電腦系統發展並不是完全沒有進展的,當時我們已經有了 Unix, Linux, DOS, Windows 也到了 Windows 98,資訊界前輩們的努力是可歌可泣的。當時,使用羅馬字元的西方語系問題比較小,因為透過拼音文字,字母並不多。

但以方塊字為主的中文,以及從中文衍生出來的東亞語系文字,如日文、韓文,在資訊界暱稱為 CKJS (Chinese, Korean, Japanese, S我忘了是哪個語系了....),在顯示上面就不是一件簡單的事情了。

所以中文有 Big5, GB碼,日文有 Shift-JIS,各國都有自己的文字編碼,但主要都以 2 Bytes 的長度來定義字碼,再從系統中找到對應的字型加以顯示。

進入到 Unicode 之後,全球的所有知名的資訊廠商幾乎都加入了該聯盟(Unicode Consortium),因為涵蓋的語系非常多,所以兩個 Bytes 根本不夠用,在 Windows 作業系統中,核心的文字都是轉換為 UCS-4 來顯示的,不同的文字檔案或檔案來源,都有各自的轉換模組,所以 Windows 切換成各國文字來顯示都不成問題。

但是轉換為檔案儲存的時候,問題又來了,Unicode 的文字檔案編碼有 UTF-16 (BE), UTF-16 (LE), UCS-4, UCS-2, UTF8, UTF7 等等,讓人眼花撩亂。

目前比較常見的文字檔編碼,除了原本的非 Unicode 編碼,最常用的應該是 UTF-8了,因為 UTF-8 的編碼當中,在西方文字的編碼與 ASCII 完全相同,但是 CKJS 的文字就不是這樣囉,有的字在 UTF-8 當中用 2 bytes, 有的用 3 bytes 來儲存,但對於系統來講都算是一個字元。

TStrings 的更迭

前面提到過,使用 Delphi 撰寫的專案當中,有些比較有歷史的專案,會使用固定長度的文字檔來儲存資料,並搭配 AssignFile, PChar 來讀取文字,進行資料判別與讀取。這個作法在 Single Byte 的文字檔,以及 Single Byte 的 Delphi 當中,使用上是很方便的,但到了 Multi-Bytes Char 的文字檔,以及支援 Multi-Bytes 的 Delphi (Delphi 2009之後的所有版本),這個作法簡直成了惡夢。

在舊的環境當中,抓一個 Byte當一個字元,聽起來很直覺。但Single Byte 的 Delphi 搭配 UTF-8 的檔案編碼,這樣的讀取在中英文夾雜的資料就會出大問題,因為中文字在 UTF-8 檔案可能是兩個 Bytes, 也可能是 3 Bytes, 抓固定長度?鐵死的!

問題不只如此,寫入也是個大問題,以往的編碼法,例如Big5,可以用字元長度乘以2來當成資料長度,但改為用 UTF-8編碼的時候,哇~~~ 固定長度直接崩潰了!

因為以往定義的固定長度,其實當中有很大的問題,發生在名詞定義上的混淆。到底這裡所指的固定長度,是固定幾個字?還是固定幾個Bytes? 這一點就沒有釐清!而檔案編碼的不同,會讓剛提到的長度定義混淆更亂!

所以,在 Delphi 2009 之後,要正確的讀取文字檔案,我自己都養成習慣,一定使用 TStringList 來處理。

TStringList 在早期的 Delphi 當中,是唯一可用來建立 TStrings 物件的 Class,在 Delphi 2009 之後的版本中,它的 LoadFromFile, SaveToFile 方法,都加入了 Encoding 這個參數,我們可以透過參數的指定,直接用對應的編碼方法載入文字檔,只要編碼指定正確,讀入的文字就會是正確的了,寫法如下:

var
   tmpStrs : TStringList;
begin
    tmpStrs := TStringList.Create;
    try
         tmpStrs.LoadFromFile('要載入的文字檔', TEncoding.UTF8);
    finally
         tmpStrs.Free;
    end;
end;

BOM 的有無

在 UTF-8 的檔案儲存中,BOM (Bytes Order Mark) 是常被用來跟非 Unicode 編碼的檔案區分的符號。但 BOM 在很多場合中又會造成問題,例如 JSON 是不接受 BOM 的。

BOM 跟 TStringList 的處理,在 2010 年前後是很熱門的話題,但 Delphi 的 TStrings 已經加入了 WriteBOM 這個屬性,預設值是 True,所以以下這段程式碼:

var
   tmpStrs : TStringList;
begin
    tmpStrs := TStringList.Create;
    try
         tmpStrs.SaveToFile('寫入文字檔.txt', TEncoding.UTF8);
    finally
         tmpStrs.Free;
    end;
end;
產生出來的文字檔,預設是有 BOM 的,用文字編輯器打開,BOM 這三個 Bytes不會被顯示出來,但是透過 UltraEdit 或類似的可以在文字與16進位模式切換的編輯器,就會看到前面多了 3 Bytes。

想要把這 3 個 Bytes拿掉,只需要把 WriteBOM 設定為 False 即可:
var
   tmpStrs : TStringList;
begin
    tmpStrs := TStringList.Create;
    try
         tmpStrs.WriteBOM := False;
         tmpStrs.SaveToFile('文字檔沒有BOM.txt', TEncoding.UTF8);
    finally
         tmpStrs.Free;
    end;
end;
這樣產生出來的 UTF-8 編碼文字檔,就不會有 BOM 了,很簡單吧。

注意事項

如果我們的 TStringList/TStrings 內容是透過 LoadFromStream 取得的,而原來的 Stream 裡面又有不同的編碼方法,這時候 WriteBOM 就不一定能正常運作。

解決方法是:建立另一個 TStringList,透過 AddStrings 把剛剛使用 LoadFromStream 的 TStringList 的內容加過來之後再存,這樣就可以了:
var
   StrsFromStream, tmpStrs : TStringList;
begin
    StrsFromStream := TStringList.Create;
    tmpStrs := TStringList.Create;
    try
         .....
         StrsFromStream.LoadFromStream(其他的Stream Instance);

         tmpStrs.AddStrings(StrsFormStream);
         tmpStrs.WriteBOM := False;
         tmpStrs.SaveToFile('文字檔沒有BOM.txt', TEncoding.UTF8);
    finally
         StrsFromStream.Free;
         tmpStrs.Free;
    end;
end;
這樣就可以了,這問題煩了我一天,所以寫個筆記跟大家分享,也讓我自己做個記錄,因為最近記憶力變差了......