技術文章

用 HotXLS 在 Delphi 中讀取 Excel 2.0 到 4.0 檔案

HotXLS 可直接從 Delphi 與 C++Builder 開啟由 Excel 2.0、3.0 與 4.0 寫出的活頁簿。這些檔案早於後來所有 .xls 檔案都採用的 OLE 複合文件容器,因此它們是完全沒有儲存包裝的原始 BIFF 記錄串流,一個為 BIFF8 打造的讀取器,在裡面連一個能辨識的結構都找不到。開啟這類檔案用的呼叫跟其他活頁簿一樣是 Open;讀取器會自行偵測格式並切換路徑

這些檔案至今仍會出現,這也是這整件事有意義的唯一原因。工程檔案庫、政府紀錄保存、控制軟體寫於 1993 年的儀器所留下的實驗室資料,以及長年運作的會計系統,都留下了 BIFF2 與 BIFF4 活頁簿。現代 Excel 出於安全考量已移除舊版轉換器,直接拒絕開啟其中好幾種,讓一批資料變成沒有任何人手上的工具能讀取的孤兒

前 OLE 活頁簿有什麼不同?

從 Excel 5.0 起的每一份 .xls,都是 OLE2 複合檔案,也就是檔案內部的一個小型檔案系統,活頁簿存放在名為 WorkbookBook 的串流中。剖析這類檔案,要先從剖析這個容器開始,說明於 以 Pascal 處理複合檔案二進位格式 一文

BIFF2 到 BIFF4 沒有容器。檔案直接以一筆 BOF 記錄開始,該 BOF 的記錄編號本身就編碼了世代:BIFF2 為 $0009,BIFF3 為 $0209,BIFF4 為 $0409。HotXLS 會先驗證 BOF 主體長度(介於四到六位元組之間)以及子串流類型(工作表為 $0010、圖表為 $0020、巨集工作表為 $0040),再確定要走原始路徑。這項驗證正是防止損毀或誤判的檔案被當成非常舊的活頁簿來解讀的關鍵

三個世代,三種記錄配置

儲存格記錄是各世代分歧最明顯的地方。BIFF2 佔用一段連續的低編號記錄,$0001$0005 涵蓋空白、整數、數字、標籤與布林值或錯誤儲存格,每筆記錄主體帶有一個三位元組屬性欄位,後來的版本則在同一位置放延伸格式索引。BIFF3 與 BIFF4 放棄了這種做法,改為沿用 BIFF5 的記錄編號與配置:$0201$0203$0204$0205,搭配兩位元組的 XF 索引

最後這個細節會導致一種特定且容易誤判的錯誤。BIFF3 或 BIFF4 的 LABEL 記錄結構上與其 BIFF5 對應版本完全相同,都是列、欄接著格式索引再接著字元數。若寫一個假設是 BIFF2 配置的讀取器,就會少讀兩個位元組,接著越界讀取記錄之後的內容,把後面的一切都誤判掉。這種症狀不是拋出例外;而是讀出一份看起來合理、實則垃圾的活頁簿

公式記錄在三個世代之間採平行編號:$0006$0206$0406。當公式產生字串結果時,該字串會出現在後面一筆獨立的記錄中,即 $0007$0207,而 BIFF2 版本使用單位元組長度前綴,而非後來使用的雙位元組前綴

為什麼公式回傳的是值而不是文字?

HotXLS 在這些檔案中讀取的是公式的快取結果,並不會嘗試重建公式運算式。這是刻意設下的界線,不是留待日後補上的缺口

BIFF2 到 BIFF4 中已剖析的運算式,其 token 編碼與 BIFF5 之後的版本有著超越表面的差異:token 長度的前綴方式不同,參照 token 的大小不同,函式索引表也在各世代之間重新編號過。把這些位元組硬塞進一個 BIFF8 運算式轉譯器,不會產生一個錯誤的公式,而是產生一個隨機的公式。讀取快取值則能給你 Excel 最後一次計算出的數字或字串,而這正是封存遷移實際需要的內容

快取值位於記錄內一個依世代而異的偏移位置:BIFF2 是第 7 位元組,BIFF3 與 BIFF4 是第 6 位元組。特殊值(字串、布林值、錯誤與空白)是以一個帶有識別碼的 $FFFF 標記字組來編碼,這與後來的 BIFF 世代所沿用的慣例相同

開啟一份檔案

呼叫端程式碼平淡無奇,而這正是重點所在。偵測是在 Open 內部發生的:

uses
  lxHandle;

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  R, C: Integer;
  V: Variant;
begin
  Book := TXLSWorkbook.Create;
  try
    if Book.Open('archive\1993-inventory.xls') <> 1 then
    begin
      Writeln('unreadable - quarantine for manual review');
      Exit;
    end;
    Sheet := Book.Sheets[1];          // Sheets[] 從 1 開始計數
    for R := Sheet.UsedRange.FirstRow + 1 to Sheet.UsedRange.LastRow + 1 do
      for C := Sheet.UsedRange.FirstCol + 1 to Sheet.UsedRange.LastCol + 1 do
      begin
        V := Sheet.Cells[R, C].Value;
        if not VarIsEmpty(V) then
          Writeln(Format('R%dC%d = %s', [R, C, VarToStr(V)]));
      end;
  finally
    Book.Free;
  end;
end;

請留意這段迴圈中的索引運算。UsedRange 的邊界從 0 開始計數,而工作表集合與儲存格存取卻都從 1 開始計數,這種不一致早於目前的 API 就存在,且為了相容性而保留下來。忘記調整就會稽核到錯誤的矩形範圍,還會若無其事地什麼異常都不回報。不必載入整份檔案就能做的低成本預先檢查,說明於 輕量級活頁簿檢視 一文

你得不到什麼,以及該怎麼處理

格式不會被解讀。HotXLS 不會剖析這些世代的 XF 與 FONT 記錄,因此字型、顏色、框線與數字格式都無法取得,Excel 曾經顯示為日期的儲存格,讀回來會是原始的序列數字

最後這一點需要在你自己的程式碼中處理,而不是在讀取器中處理,原因很誠實:BIFF2 到 BIFF4 中的數字格式不夠可靠,無法用來自動判斷是否為日期。一欄五位數的數字,可能是日期,也可能是零件編號。請刻意轉換,使用活頁簿的日期系統,其規則說明於 日期序列值、1904 系統與數字格式 一文:

// 依欄逐一判斷,絕不依單一值判斷:五位數的數字可能是
// 日期,也可能是零件編號,而舊版格式不會告訴你答案
if ColumnHoldsDates(C) then
begin
  // 兩種日期系統相差 1462 天,因此同一個序列值
  // 對應到相差四年的兩個日期。請從活頁簿讀取所用的系統,
  // 不要憑空假設
  if Book.Date1904 then
    Writeln(DateToStr(SerialToDate1904(V)))
  else
    Writeln(DateToStr(SerialToDate1900(V)));
end
else
  Writeln(VarToStr(V));

還有兩項結構性提醒補足整個圖像。密碼保護與字碼頁記錄出現在單一工作表串流內部,而非工作表層級的串流裡,因為根本沒有工作表層級的串流可以放,所以必須在工作表脈絡中辨識它們。而一份 BIFF2 到 BIFF4 檔案剛好只含一個工作表子串流;多工作表活頁簿要等到格式取得容器後才出現

因此務實的遷移路徑是兩個步驟:先讀取舊檔取得其中的值,再寫出一份帶有你自己套用格式的現代活頁簿,承載那些值。舊版讀取、現代寫入以及介於兩者之間的一切,都在同一套適用 Delphi 與 C++Builder 的函式庫中運作,說明於 HotXLS Delphi 試算表元件頁面