適用於 Delphi 與 C++Builder 的 HotXLS Excel Library,以純 Object Pascal 讀寫每一份舊式 .xls 檔案背後的 Compound File Binary 容器。TlxCompoundFile 類別直接針對 TStream 實作 [MS-CFB] 第 3 版的版面配置──標頭、DIFAT、FAT 鏈、MiniFAT 與目錄樹──整條路徑完全不用到 ole32.dll,也不涉及 COM 的 IStorage
這聽起來像是底層管線工程,而在過去二十年裡,這確實是別人擁有的管線工程。每一個碰過 .xls 檔案的 Delphi 程式碼庫,都會直接呼叫 StgOpenStorage、取得一個 IStorage、再從中取出 Workbook 串流。三行程式碼,運作良好,沒有人再多想──直到有一天,同一段程式碼必須跑在一個沒有 Windows 的地方
為何 StgOpenStorage 在伺服器上會失效?
COM 結構化儲存 API,恰好就在現代 Delphi 程式碼所處的那些部署情境中失效,而原因跟檔案格式本身完全無關。StgOpenStorage 是 ole32.dll 裡的一個 Win32 進入點:它要求檔案系統上的一個路徑、要求呼叫端執行緒已初始化 COM,而且要求整個環境必須是 Windows。路徑要求最先造成傷害,因為一個接收已上傳活頁簿的 REST 端點,其位元組資料只存在於緩衝區裡,並不在磁碟上──所以你得把緩衝區寫入一個暫存檔、開啟它、讀回來、再刪除它,於是你現在還多了一個很容易在高負載下出錯的暫存檔生命週期要管理。ILockBytes 是官方文件記載的逃生口,但要在 TMemoryStream 上接出一套自訂實作,牽涉到的 COM 互通複雜度,超出大多數團隊願意承受的範圍。初始化要求接著咬人一口,通常發生在某個沒有人呼叫過 CoInitialize 的服務工作執行緒上,而平台要求則在目標環境是 FPC 下的 Linux、某個容器映像檔,或 macOS 的那一刻,直接終結了整個對話。因此,HotXLS 把建構在 StgOpenStorage 之上的傳統 lxOLE 路徑,保留為預設做法,因為它已經久經考驗,既有呼叫端也不必為此變更任何東西;TlxCompoundFile 則是提供給其他所有人的選擇性替代方案
標頭與 FAT 鏈實際上說了什麼
複合檔案的前 512 個位元組,就能回答你在讀取任何一個酬載位元組之前需要知道的所有結構性問題。[MS-CFB] §2.2 把偏移量 0 處的標頭簽章,固定為 D0 CF 11 E0 A1 B1 1A E1 這八個位元組,lxIsCompoundStream 就是恰好檢查這一點,事後還會還原串流位置,讓呼叫端可以做偵測而不擾動任何東西。還有另外四個欄位決定了整體幾何配置:位於 0x1C 的位元組順序必須是 0xFFFE,這同時也兼作一次成本低廉的第二重簽章檢查;位於 0x1E 的磁區位移量,給出的磁區大小是 1 shl SectorShift,因此第 3 版用位移 9 對應 512 位元組的磁區,第 4 版則用位移 12 對應 4096 位元組;位於 0x20 的迷你磁區位移量是 6,代表迷你磁區為 64 位元組;而位於 0x38 的迷你串流門檻值則是 4096。接下來的位址運算,是最常出錯的地方。磁區 0 緊接在標頭之後開始,所以磁區 N 的起始位元組偏移量是 512 + N * SectorSize──請注意這裡是字面上的 512,而不是 SectorSize。在第 3 版檔案上,這兩者恰好相同,錯誤因此能永遠藏起來;在第 4 版檔案上,卻會悄悄讀到錯誤的磁區,這正是為何 HotXLS 把這個邏輯,集中放在單一函式 SidToOffset 裡的原因
一個複合檔案,本質上是藏在一個檔案裡的 FAT 檔案系統,所以讀取它意味著要走訪一串串磁區編號的鏈結串列,其中 FAT[n] 存放的是緊接在磁區 n 之後的磁區編號。有三個哨兵值用來終止或標註一條鏈──ENDOFCHAIN、代表屬於 FAT 本身的磁區的 FATSECT,以及代表 DIFAT 磁區的 DIFSECT──這三者讀出來都是帶符號的負數 32 位元整數,這讓迴圈的判斷條件保持簡單。找到 FAT 還需要多一層間接處理:DIFAT 是一個磁區編號陣列,記錄著 FAT 各磁區的所在位置,其前 109 個條目位於標頭偏移量 0x4C 處。TlxCompoundFile 會走訪這 109 個條目,遇到第一個負數條目就停止,並把每個 FAT 磁區串接成一個扁平的 Integer 陣列。在 512 位元組的磁區上,109 個 FAT 磁區、每個 128 個條目,代表可定址的磁區共有 13,952 個,也就是說容器大約要到 6.8 MiB 左右,DIFAT 才必須溢出成自己的一條鏈
第二張配置表存在的原因,是 512 位元組磁區在存放小型串流時,大部分空間都被浪費掉。任何低於 4096 位元組門檻的串流,根本不會存放在一般磁區裡:它活在迷你串流(mini stream)中——迷你串流本身也是掛在根目錄項下的一個普通串流,再被細分為 64 位元組的迷你磁區,並透過一張獨立於一般 FAT、根植於標頭偏移 0x3C 的並行 MiniFAT 串接起來。打開一個真實的 .xls 檔案,Workbook 串流會落在一般 FAT 路徑上,摘要資訊串流卻落在迷你磁區空間裡,這正是為何一個只涵蓋 FAT 路徑的實作,會一路正常運作,直到需要讀取文件中繼資料時才露出破綻。目錄是第三種結構,也是讓整個容器可被走訪的關鍵:每個項目恰好 128 位元組,每個 512 位元組磁區能容納四個,前 64 位元組存放 UTF-16 名稱,0x40 存放其位元組長度,0x42 存放物件型別(1 = 儲存區、2 = 串流、5 = 根目錄),0x44、0x48 與 0x4C 存放樹狀結構連結,0x74 存放起始磁區,0x78 存放 32 位元的串流大小。該名稱長度計入含結尾空字元在內的位元組數,因此實際字元數應為 NameLen div 2 - 1,這一步差一算錯,就會把串流名稱讀成 Workboo
從記憶體緩衝區中取出 Workbook 串流
TlxCompoundFile.OpenStream 把以上所有細節,隱藏在一個只需傳入串流名稱、就能回傳一個持有完整實體化位元組的 TlxCfbStream 的呼叫背後。偵測、載入、擷取這一整套流程,全部針對一個 TBytesStream 執行,完全不會碰到磁碟
uses
Classes, SysUtils, lxCompoundFile;
function ExtractBiffPayload(const Blob: TBytes): TBytes;
var
Src: TBytesStream;
Cfb: TlxCompoundFile;
Wb: TlxCfbStream;
begin
SetLength(Result, 0);
Src:= TBytesStream.Create(Blob);
try
if not lxIsCompoundStream(Src) then
Exit; // not a CFB container at all
Cfb:= TlxCompoundFile.Create;
try
Cfb.LoadFromStream(Src); // header, FAT, directory, MiniFAT
Wb:= Cfb.OpenStream('Workbook'); // BIFF8
if Wb = nil then
Wb:= Cfb.OpenStream('Book'); // BIFF5 / BIFF7
if Wb <> nil then
try
Result:= Wb.Data;
finally
Wb.Free;
end;
finally
Cfb.Free;
end;
finally
Src.Free;
end;
end;
這裡有兩個細節值得特別提出來說。LoadFromStream 接受一個預設為 False 的 AOwnsStream 旗標,所以來源串流的責任,仍然由呼叫端保留──這是刻意的設計,因為常見情況是應用程式本身早就擁有這個串流。而 OpenStream 回傳的是一個 TlxCfbStream,它擁有自己那份位元組副本,透過 Data、Size、Read、Seek 與 CopyTo 對外呈現。對一份大型活頁簿而言,這份複製確實是一項真實的成本,也是這種「回傳物件在容器釋放後仍然有效」設計所要付出的誠實代價。當活頁簿大到讓完整的記憶體內複製,整個做法都顯得不合適時,超大型試算表適用的串流直讀器 會是更好的進入點
為何加密的 XLSX 看起來會像一個 XLS 檔案?
因為在容器層級上,它確實就是一個 .xls 檔案──而這正是自行掌握這一層所帶來的實際回報。用十六進位編輯器開啟一個加密的 .xlsx,前八個位元組是 D0 CF 11 E0 A1 B1 1A E1,逐位元組與一個 1997 年代的 .xls 完全相同,因為 [MS-OFFCRYPTO] 加密並不是就地加密 ZIP 封裝:它是把整個封裝包在一個 CFB 容器裡,成為一個名為 EncryptedPackage 的串流,旁邊還有一個描述演算法的 EncryptionInfo 串流。因此,簽章識別的是容器本身,對酬載內容則毫無交代。要分辨一個 BIFF 活頁簿與一個已加密的 OOXML 封裝,得靠讀取目錄,也就是在 LoadFromStream 之後,掃描 EntryCount 與 Entries,或做一對 HasStream 探測
type
TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);
function ClassifyContainer(AStream: TStream): TCfbPayload;
var
Cfb: TlxCompoundFile;
E: TlxCfbEntry;
I: Integer;
begin
Result:= cpUnknown;
Cfb:= TlxCompoundFile.Create;
try
Cfb.LoadFromStream(AStream);
for I:= 0 to Cfb.EntryCount - 1 do
begin
E:= Cfb.Entries(I);
if E.EntryType <> cfbStream then
Continue;
if E.Name = 'EncryptedPackage' then
Result:= cpEncryptedOoxml
else if (E.Name = 'Workbook') or (E.Name = 'Book') then
Result:= cpBiffWorkbook;
end;
finally
Cfb.Free;
end;
end;
目錄名稱本身就值得特別提出警告:摘要資訊串流的名稱前面帶有一個 0x05 控制字元,所以拿一般顯示用字串去比對,永遠不會相符,而一個天真的日誌輸出,也只會把它們渲染成亂碼。這個分類動作之後的一切──推導金鑰、檢查密碼驗證碼──是另一個獨立的問題,在 為何 Excel 會拒絕以錯誤加密模式加密的活頁簿 一文中有討論。容器層能告訴你的,就只是你正站在哪一扇門前而已
寫出一個 Excel 真的能開啟的容器
TlxCompoundFile 的寫入端,刻意設計得比讀取端狹窄許多,理解箇中原因,能省去一場與規範較勁的爭論。[MS-CFB] 允許一個極其龐大的合法容器空間:多層儲存體、正確平衡的紅黑目錄樹、迷你串流、DIFAT 鏈。Excel 只輸出這個空間裡的一小角落,讀取時則涵蓋稍微大一點的範圍。HotXLS 寫入的角落還要更小──就是 Excel 確實能載入的最小集合。每一個串流都走一般的 FAT、完全不走迷你串流路徑,這會多花一些磁碟空間,換來的是正確性:一個 300 位元組的摘要串流,Excel 原本會把它打包進五個 64 位元組的迷你磁區裡,而 HotXLS 卻讓它占用整整一個 512 位元組的磁區,對一份活頁簿而言,比起在寫入路徑上還要維護第二張配置表、第二種鏈結走訪邏輯,以及支撐它的根條目串流,這點空間浪費根本是雜訊等級。目錄條目在根節點之下,形成一條扁平的手足鏈,每個節點都著色為黑色,輸出順序是固定的:標頭佔位符、串流資料磁區、目錄磁區、FAT 磁區,接著再往回定位、把只有到最後才能得知的磁區編號,重新寫入標頭。FAT 本身則透過一個短小的定點疊代迴圈來決定自己的大小,因為新增 FAT 磁區,有可能把磁區數量推高到需要再多一個 FAT 磁區的程度
procedure SaveAsCompoundFile(const Dest: string; const BiffBytes: TBytes);
var
FS: TFileStream;
Cfb: TlxCompoundFile;
begin
FS:= TFileStream.Create(Dest, fmCreate);
try
Cfb:= TlxCompoundFile.Create;
try
Cfb.CreateNew(FS); // v3 header, 512-byte sectors
Cfb.AddStream('Workbook', BiffBytes);
Cfb.Save; // data -> dir -> FAT -> header
finally
Cfb.Free;
end;
finally
FS.Free;
end;
end;
這份實作在哪裡止步
有三個邊界值得直接說清楚,因為一個對邊界情況悄悄處理錯誤的容器讀取器,比一個直接拋出例外的讀取器還要糟糕。TlxCompoundFile 只讀取標頭中常駐的 109 個 DIFAT 條目,不會繼續走訪 0x44 處的 DIFAT 鏈,這讓一個可讀取的容器,在 512 位元組磁區下上限大約是 6.8 MiB──這個上限,遠遠高於 HotXLS 在實務現場遇到的真實 .xls 檔案,但終究還是一道硬性上限,而寫入端也明確地遵守同一個限制,而不是輸出一個自己都無法描述的容器。第二,採用 4096 位元組磁區的第 4 版容器,雖然磁區大小運算能夠適應,但程式碼本身並不是針對它調校的,而且 64 位元的串流大小欄位並未被參考:HotXLS 只讀取偏移量 0x78 處的低 32 位元,高半部完全不理會,這對第 3 版而言是對的,僅限第 3 版才對。第三,條目查找是在整個目錄清單上依名稱做扁平掃描,而不是從某個父儲存體沿著紅黑樹往下走,所以巢狀儲存體是靠名稱碰撞來解析,而不是靠路徑──一份 .xls 檔案所需的每一個串流,都位於頂層,這正是這種較簡單設計站得住腳的原因,但如果程式碼預期要定址 SomeStorage/SomeStream,就找不到它
這些邊界都不會改變這個單元存在的目的。掌握容器層這件事,把 .xls 的處理,變成了一般的 Object Pascal 工作:能從一個位元組陣列解析、不需要檔案系統就能測試、能移植到編譯器所支援的任何平台,而且完全不涉及 COM 套間(apartment)。它同時也淘汰了偵測捷徑,因為現在要識別一份活頁簿,靠的是讀取它的目錄,而不是它的前八個位元組──這正是 不開啟整份活頁簿就能列出工作表名稱 一文背後的同一套紀律
TlxCompoundFile 隨附於 HotXLS Excel Component(適用於 Delphi 與 C++Builder)之中,與建構在它之上的 BIFF 及 OOXML 層一同出貨;產品頁面提供完整的單元參考文件與支援的編譯器對照表