HotXLS 2.376.0 修復了經典 XLS 寫入器中的 BIFF 記錄長度漂移:PivotTable 檢視的 SXEx 發射器在標頭宣告主體長度為 24 位元組,卻隨後追加了 26 位元組。BIFF 讀取器信任宣告長度,因此多出的兩個位元組會讓後續所有內容失去同步;將 PivotTable 與圖表工作表配對的活頁簿重新開啟後,圖表就會消失
有意思的部分不只是多寫了一個字。錯誤位置與症狀之間的距離才是關鍵。錯誤發生時沒有任何內容失敗。Pivot 記錄順利序列化,檔案寫入沒有報錯,Excel 也能開啟它,損壞卻要到下游數百個位元組處、完全無關的子串流中才顯現。這種距離是所有長度前綴二進位格式的典型特徵,在你為其中任何一種格式再寫發射器前,都值得先理解
一個錯誤的記錄長度為什麼會破壞整個工作表串流
BIFF8 活頁簿串流除了自身的算術關係,沒有任何其他成框機制。每筆記錄由 4 位元組標頭組成:記錄 ID 2 位元組加上主體長度 2 位元組,後面緊跟正好那麼多的載荷位元組([MS-XLS] 2.1.4)。沒有分隔符號、魔數、校驗和或重新同步點。讀取器之所以能落在下一筆記錄上,只是因為上一筆記錄如實說明了自身大小。因此追蹤多出的兩個位元組做了什麼。讀取器讀完 SXEx 標頭,跳過標頭承諾的 24 位元組,落在過大主體的兩個剩餘零位元組上。它把這些零讀成記錄 ID $0000,再把後面的工作表 EOF 記錄 ID($000A)讀成這個幽靈記錄的長度,並盡職地向後跳過十個位元組。從這以後,每個標頭都在錯誤的偏移處讀取。在失敗的活頁簿中,這產生了重新開啟後 _Chart 為 nil 的圖表工作表,還產生了顯示 $18AF 被解釋為記錄 ID 的除錯傾印。這兩個值都不在 Pivot 程式碼附近
發射器和寫入器從不互相核對
之所以會發生漂移,結構原因在於 HotXLS 把 BIFF 記錄建構為 TXLSBlob,標頭和載荷是兩個獨立事實。EmitSXEx 寫入記錄 ID,然後用 Blob.AddWord(24) 寫入長度,再逐欄位追加主體內容。這個 24 是手工計算的常數,從未根據後續位元組推導,也從未與後續位元組核對。寫入路徑也沒有補救這一點:AddRec 將 blob 交給 TXLSBlobList.Append,後者把 Data.DataLength 個位元組原樣複製到輸出串流。DataLength 才是真實位元組數,因此寫入器忠實地在宣告 24 的標頭後發出 26 位元組主體。兩半都完全按照指令工作,卻沒有人負責注意到它們之間的矛盾。HotXLS 在重播保留載荷時已經避免了這一點:TXLSWorkbook.StoreDConnBlobs 根據實際主體長度計算標頭長度字,而不是使用字面值,這正是 blob 重播從未漂移的原因
[MS-XLS] 2.4.282 對 SXEx 規定了什麼
規範對大小的規定很明確,因此修復是機械性的。[MS-XLS] 2.4.282 將 SXEx 主體定義為一個 4 位元組的 grbit,後面跟著十個 2 位元組欄位:csxformat、cchErrorString、cchNullString、cchTag、csxselect、crwPage、ccolPage、cchPageFieldStyle、cchTableStyle 和 cchVacateStyle。4 加 20 等於 24。舊發射器按照規範定義的十個零字,卻寫入了十一個;匿名的 AddWord(0) 呼叫沒有攜帶欄位名稱,因此在審查時憑肉眼計數,可靠性正如你想像的那樣。預先配置是線索:TXLSBlob.Create(28) 只為 4 位元組標頭加 24 位元組主體申請空間,但 blob 每次呼叫都會超過這個提示,而且由於 AdjustBufferSize 會按需重新配置,超出過程是靜默的。任何程式碼一開始就會越過的容量提示,都值得在序列化器中再看一眼
function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
Blob: TXLSBlob;
begin
Blob := TXLSBlob.Create(28); // 4 位元組標頭 + 24 位元組主體
Blob.AddWord($00C6);
Blob.AddWord(24);
Blob.AddByte($02);
Blob.AddByte($00); // grbit1 = fPrintTitles
Blob.AddByte($00);
Blob.AddByte($00); // grbit2
// 十個零字完成 [MS-XLS] 2.4.282 規定的 24 位元組主體
// 宣告長度必須與寫入位元組數一致,否則此後的每筆記錄
// 都會被錯誤解析
Blob.AddWord(0); // csxformat
Blob.AddWord(0); // cchErrorString
Blob.AddWord(0); // cchNullString
Blob.AddWord(0); // cchTag
Blob.AddWord(0); // csxselect
Blob.AddWord(0); // crwPage
Blob.AddWord(0); // ccolPage
Blob.AddWord(0); // cchPageFieldStyle
Blob.AddWord(0); // cchTableStyle
Blob.AddWord(0); // cchVacateStyle
AddRec(DataList, Blob);
Result := 1;
end;
為什麼整個 PivotTable 測試套件都沒有發現它
因為現有 Pivot 測試從未透過檔案往返。它們建構活頁簿,針對記憶體模型斷言,然後停止;記憶體斷言看不見只存在於序列化位元組串流中的長度不匹配。從 Delphi 寫入 BIFF8 PivotTable 記錄涵蓋的記錄集合按這個標準經過充分測試,卻仍然交付了會破壞串流的發射器。這個缺陷還需要第二個功能才會可見:只有 Pivot 工作表、後面沒有太多內容時,檔案仍然能重新開啟,因為損壞越過了一個沒有人檢查的子串流末尾。只有 PivotTable 與圖表工作表組合,且圖表工作表和繪圖位於工作表之後的子串流中時,靜默錯位才會變成明顯遺失的物件
// PivotChartRoundTripThroughLinkRecords,壓縮版
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath); // 錯誤解析在這裡發生
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);
修復前,Wb.Sheets[3]._Chart 在那一行是 nil,因為讀取器早在到達圖表 BOF 之前就已經丟失了子串流邊界。最終捕獲 Pivot 序列化 bug 的斷言,實際上是關於圖表的斷言
如何從第一個錯誤記錄讀回錯位的 BIFF 串流
遍歷標頭鏈並列印出來,因為錯位的 BIFF 串流會在資料看起來錯誤之前很久就從結構上暴露自己。從子串流 BOF($0809)開始,讀取 ID 和長度,以 4 加長度的步長前進並重複。串流保持對齊時,你會落在合理的記錄 ID 上,鏈也會準確在 EOF($000A)結束。一旦發生漂移,就會出現不存在的 ID、越過緩衝區的長度,或直接越過 EOF 原本應該出現的位置
// 遍歷 BIFF 記錄串流,在第一個不可能成立的標頭停止
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
Pos: LongWord;
Id, Len: Word;
begin
Pos := 0;
while Pos + 4 <= Size do
begin
Id := PWord(Buf + Pos)^;
Len := PWord(Buf + Pos + 2)^;
// 零 ID 從來不是合法記錄,越過緩衝區的主體說明鏈
// 在上游某處已經發生漂移
if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
begin
WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
Break;
end;
WriteLn(Format('%6d id=$%.4x len=%d', [Pos, Id, Len]));
if Id = $000A then
WriteLn('-- EOF, substream ends cleanly --');
Inc(Pos, 4 + LongWord(Len));
end;
end;
然後從輸出末尾倒著讀,並記住一條規則:第一個解析失敗的記錄幾乎從來不是罪魁禍首,而是受害者。罪魁禍首是它前面的那一筆、最後一筆沒有報錯地解析完成的記錄,因為謊報自身長度的記錄總能順利完成解析。在這個案例中,遍歷在幽靈 $0000 記錄處停止,而它前面就是 SXEx。把該記錄的宣告長度逐位元組與規範中的欄位清單比較,算術關係要麼成立,要麼不成立。如果連第一筆合理記錄都到不了,問題就在更低一層,也就是承載 Workbook 串流的 OLE2 複合檔案中,繼續列印記錄層級資訊也不會有幫助
讓發射器無法謊報自身長度
持久修復不是寫對一個常數,而是移除寫出錯誤常數的機會。先為長度字預留位置,寫出主體內容,再根據實際產生的位元組數回填標頭。HotXLS 暴露了所需介面:TXLSBlob.DataLength 提供目前偏移,SetWord 可以寫回已經輸出的位置
function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
Blob.AddWord(RecId);
Result := Blob.DataLength; // 記住長度字所在的位置
Blob.AddWord(0); // 佔位符,由 EndRecord 回填
end;
procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
Body: LongWord;
begin
Body := Blob.DataLength - LenPos - SizeOf(Word);
if Body > 8224 then
raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
Blob.SetWord(Word(Body), LenPos);
end;
也要誠實說明保證的邊界。斷言輸出位元組等於 2 加 2 加宣告長度,只適用於載荷不超過 BIFF8 上限 8224 位元組的記錄。更大的主體會在標頭合法地宣告 8224,並透過 $003C Continue 記錄繼續,這正是 HotXLS Pivot 快取和連線寫入器處理大載荷的方式。因此不變量是有條件的:低於上限時,輸出 blob 長度必須等於宣告長度加四;超過上限時,則由拆分器負責算術。應當把這項差別編碼在輔助函式中,而不是藏在註解裡。同樣的推理可以移植到所有標籤長度值格式,而不只是 BIFF。發射器在尚未知道大小之前就宣告大小,寫下的是程式碼無法檢查、審閱者無法計數的主張;它會一直工作到下游第二個功能落地為止
這裡討論的 BIFF8 寫入器、Pivot 記錄發射器和圖表子串流,都屬於面向 Delphi 和 C++Builder 的 HotXLS Delphi 試算表元件,無需安裝 Excel 即可讀寫 XLS、XLSX 和 ODS