技術文章

用 HotXLS 在 Delphi 框住 BIFF PivotCache 記錄

BIFF PivotCache 子串流把樞紐分析表的快取資料集與顯示它的檢視分開存放,而 HotXLS 讀寫這個子串流靠的是檢查記錄主體,不是信任記錄編號。這個區分就是全部的故事:同一個記錄編號,依產檔的寫入端不同,帶著兩種互不相容的主體版面,所以讀取端用看到的第一筆記錄主體決定框定

當樞紐分析表必須活過一次來回,您就會撞上這一層。沒有快取的樞紐檢視是個空殼,Excel 打開檔案時會從來源範圍重建快取,這在來源範圍還在時都沒問題——直到來源範圍已經刪了、資料是從查詢貼上的、或這本工作簿是一份歸檔的結帳、開啟時一個位元組都不能變

兩個結構,檔案裡的兩個位置

快取資料與快取定義住在工作簿的不同部位,把它們混為一談是第一件要做對的事。快取記錄自己形成一個子串流,[MS-XLS] §2.1.7.12 給出 PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF。注意缺席的是什麼:這條產生式開頭沒有 BOF

定義反而坐在工作簿 globals 裡,形式是 PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE](§2.1.7.20.3),位置在格式化記錄之後、BoundSheet 與 Country 記錄之前。於是一個快取在相隔數百條記錄的兩處被描述,而連接兩者的是一個必須在三個位置同時相符的串流識別碼

HotXLS 在 BIFF8 的 PivotCache 框定:帶著 SXStreamID 的 PIVOTCACHEDEFINITION 坐在工作簿 globals 裡、格式化之後 BoundSheet 之前;快取記錄則住在 _SX_DB_CUR storage 底下、以四位大寫十六進位命名的串流,內容是 SXDB、SXDBEx、SXFORMULA、FDB 與 DBB 記錄且無 BOF;SXStreamID.idStm、SXDB 的 idstm 欄位與串流名稱必須相符
單一樞紐快取在相隔數百條記錄的兩處被描述,由一個必須在 globals、SXDB 標頭與子串流名稱三處同時相符的串流識別碼接起來

每個快取歸屬於 _SX_DB_CUR 底下一個串流,串流名稱是其識別碼的四位大寫十六進位拼寫。SXStreamID.idStmSXDB 標頭裡重複出現的 idstm 欄位,與那個串流名稱,三者必須全部相符。配置新識別碼時,先把檔案裡已經讀到的每個號碼都保留起來,否則新快取可能認領一個屬於舊快取、而讀取端還沒走到那裡的號碼

還有一個識別碼會咬人。樞紐檢視裡的 iCache 值是對應 SXStreamID 在全域序列中從 0 起算的位置,不是一個可以由您挑選的快取識別碼。寫出時必須從快取物件映射到它實際的輸出位置,既有的檢視也得跟著重新編號,否則升級一個快取,會悄悄把某個檢視指向另一個快取

var
  Book: TXLSWorkbook;
  Cache: TXLSPivotCache;
  Field: TXLSPivotCacheField;
  V: TXLSPivotCacheValue;
begin
  Book := TXLSWorkbook.Create(nil);
  try
    Book.LoadFromFile('sales.xls');
    Cache := Book.PivotCaches.Add;
    Cache.SourceRangeSheet := 'Data';
    Cache.SourceFirstRow := 1;  Cache.SourceFirstCol := 1;
    Cache.SourceLastRow := 500; Cache.SourceLastCol := 6;
    Cache.SourceDataType := 1;        // SXVS SHEET, MS-XLS 2.4.317
    Cache.RefreshOnLoad := False;     // 信任快取記錄
    Cache.SaveData := True;

    Field := Cache.AddField('Region', xlpcftString);
    V.ValueType := xlpcftString;
    V.StrValue := 'North';
    Field.FindOrAddItem(V);

    Cache.SetRecordCount(0);          // 先清除,再配置記錄格大小
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

那個雙重 SetRecordCount 不是迷信。RecordCount 是一次普通的屬性寫入,不配置記憶體,而內部成長路徑只初始化新加入的列,於是透過標頭路徑設了計數的快取,可能落得一個零長度的索引格。之後對 RecordIndices 的寫入會無聲無息地被丟掉,連錯誤都不報。把計數歸零再設回來會重建索引格,而且這件事必須在每個欄位都加完之後做,因為列寬來自欄位數

為什麼記錄編號告訴不了您主體版面?

因為記錄編號與主體版面是在不同時間點改的,兩者的映射不是一個函數。舊集合裡有一個編號只出現在舊寫入端產出的檔案,這讓它成為單向可靠的訊號。另一個編號則是真的有歧義:正確的檔案裡有它,一段中間版本——用新編號配舊主體版面的那些——裡也有它

所以框定必須從主體決定,而且每個快取子串流決定一次,不是每條記錄一次。HotXLS 從每個子串流第一筆 SXDBB 記錄的長度鎖定方言。在規格的框定裡,一個 SXDBB 恰好裝一條快取記錄,長度等於一個列寬。在較舊的打包框定裡,第一筆記錄能塞幾列就塞幾列,所以對任何多於一列的快取,它至少是兩個列寬。只要兩個預測不同,這個比對就有決定性

HotXLS 的 SXDBB 框定鎖定:一個記錄編號帶兩種互不相容的主體版面,讀取端拿第一筆 SXDBB 記錄長度對照列寬——一個列寬鎖定規格方言,兩個以上列寬鎖定舊式打包框定,平手採規格讀法,且方言每個快取子串流鎖定一次,不是每條記錄
記錄編號決定不了主體版面,因為兩者在不同時間改變;HotXLS 以第一筆 SXDBB 長度、每個子串流鎖定一次方言,平手時採規格讀法

兩者相同時,讀取端採規格讀法,原則是 Excel 寫出的檔案遠多於中間版本建置寫出的檔案。這個盲點按構造就很窄,而且就算真的踩到,檔案本身照樣逐位元組原樣重播,受影響的只有暴露給呼叫端的強型別索引

索引寬度住在另一條記錄裡

SXDBB(§2.4.276)按欄位順序,為每個設了相異值旗標的快取欄位帶一個索引,而每個索引的寬度在別處決定:對應的 SXFDB 欄位記錄(§2.4.283)宣告一個短項目旗標,那個旗標說明索引佔兩個位元組還是一個。兩條記錄、一份未言明的契約,以及規格裡連接兩者的唯一一句話

這個耦合正是自製編碼出錯的地方。HotXLS 較早的寫入端把每個欄位打包進最少位元、列與列之間補到位元組邊界,單獨看站得住腳,卻與同一個寫入端剛在 SXFDB 裡宣告的寬度直接矛盾。一個有三個相異值的欄位,在一條記錄裡被描述成一個位元組寬,在另一條裡卻只佔兩個位元。修法不是修正算術,而是把寬度決定抽成一個兩個發射器都呼叫的函式,兩條記錄從此無法漸行漸遠。這正是BIFF 記錄長度宣告漂移一文描述的同類缺陷:宣告的大小與實際主體各奔東西

完全不去讀這些記錄的後果值得講明白,因為它很容易被低估。讀取端跳過記錄索引的那段時間,每個從檔案載入的快取,每一列的每一個欄位都回報索引零——也就是每一列都指向每個欄位的第一個值。這不只是內省能力變弱:樞紐求值路徑與快取到儲存格的填值路徑都吃那張格。而來回測試抓不到它,因為還在原樣重播的快取,是從原始位元組寫回去的

// 來源旗標告訴您手上拿的是什麼、哪些可以改寫
if Cache.FromRawBlobs then
begin
  Writeln('stream id        : ', IntToHex(Cache.StreamId, 4));
  Writeln('legacy framing   : ', Cache.RawFramingIsLegacy);
  Writeln('own storage      : ', Cache.RawHasStorageStream);
  Writeln('model complete   : ', Cache.RawModelIsComplete);
  // 只有這裡每條記錄都有模型時,重新發射才是無損的
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

改寫快取什麼時候才是無損的?

只有三個條件同時成立,而 CanUpgradeFraming 就是回答這個問題的那個單一屬性。快取必須還在原樣重播,子串流必須是這個程式庫以前寫錯的那些框定之一,讀取端必須已為其中每一條記錄建出完整的強型別模型。Excel 寫出的快取永遠不夠格,因為它的子串流帶著 HotXLS 沒有模型的記錄,從模型重新發射會把它們丟掉

完整性檢查比表面看起來嚴。讀取端只以不透明位元組保留的記錄,就把模型打成不完整。發射器無法重現的公式記錄宣告計數也一樣,因為重新發射會把「宣告了若干條公式記錄」改寫成「宣告了零條」,而檔案裡一個無法重現的值,等價於一條無法重現的記錄

刻意的保守也貫穿寫入端。索引被夾限進合法範圍,而不是編碼成帶外哨兵值,因為規格只定義了「指向相異值序列的索引」,其他什麼都沒定義,而空儲存格本身就是那個序列裡的一個值。超過 BIFF 記錄上限的快取記錄主體根本不寫——那需要數千個快取欄位,在 BIFF8 欄數上限內本來就到不了;退路是 Excel 從來源範圍重新整理,這是定義過的行為,不是損壞的檔案

日期帶著最後一個跨記錄相依。序號到日期的轉換取決於工作簿的日期系統,而記錄發射器看不見工作簿,所以基準日期的選擇以參數傳入,預設 1900 系統,由工作簿層級的存檔路徑供應。1900 系統下序號直接就是值;1904 系統相差 1462 天。日期序號更完整的處理在日期序號、1904 系統與數字格式

如果您工作在檢視層而不是快取層,描述可見樞紐的記錄記錄在BIFF8 樞紐分析表記錄集,計算端的行為則在計算欄位、計算項目與重新整理。三個層都隨 HotXLS Delphi spreadsheet component 出貨,正因如此,您才能載入一份老工作簿、看清它的快取實際裝了什麼、並在動手改寫之前判斷這樣做安不安全