幾乎舊版 Excel 二進位格式的每個部分,都是帶著乾淨的雙位元組型態與雙位元組長度的單一記錄。儲存格是 LABELSST 或 NUMBER。合併區域是 MERGEDCELLS。您可以沿著記錄一次走一筆,並依型態字來分派,讀出工作表的大部分內容。樞紐分析表打破了這種節奏。單一樞紐分析表不是一筆記錄,而是一段由數十個彼此協作的記錄組成的小型程式,分散在同一個 OLE 複合檔案串流中的兩個不同位置,而且它們之間的關係是位置性、位元打包且不容失誤的。這就是大多數 BIFF8 讀取器要麼完全略過、要麼保留為不透明位元組的結構,因為要從頭寫出一個樞紐分析表,意味著必須重建 Excel 本身維護的每一個交叉參照
樞紐分析表之所以難,是因為它其實是焊在一起的兩個產物。其一是樞紐分析快取,一份具備自身子串流的來源資料自足快照;其二是資料表檢視,說明哪些欄位位於哪個軸上的版面配置。快取和檢視透過索引互相參照。只要弄錯一個索引,檔案就會開成重新整理錯誤或靜默的空白格線
樞紐分析快取本身就是一個子串流
快取作為完整的 BIFF 子串流存在於活頁簿全域串流中,由檔案型態為 0x0006 的 BOF 記錄框定,該值標記樞紐分析快取,不同於活頁簿的 0x0005 或工作表的 0x0010,並由對應的 EOF 關閉。在這個框架內,結構是固定的。SXDB 記錄是快取標頭。它攜帶記錄計數、快取欄位數量,以及資料表檢視將引用以將自身繫結到此快取的串流識別碼。每個來源欄位隨後會貢獻一個 SXFDB 欄位定義記錄,後接對其進行分類的 SXFDBType,然後是該欄位取得的唯一值,以每個不重複值輸出一個項目記錄的形式送出
項目記錄是快取真正發揮價值的地方。文字值變成 SXSTRING,數值變成 SXNUM,邏輯值變成 SXBOOLEAN,公式錯誤變成 SXERR。快取不儲存來源格線,而是儲存每個欄位的不重複值,以及一張索引表,說明在記錄 n 中,每個欄位取的是哪個不重複項目。這就是為什麼以程式建立樞紐分析表不是複製儲存格的問題。您必須掃描來源範圍、從其值推斷每個欄位的型別、把它們去重成具型別的項目清單,並把每一列記錄成項目索引的 tuple。HotXLS 正是這樣做:全數值欄位會輸出為 SXNUM 項目,混合文字欄位會變成 SXSTRING 項目,而日期則沿用同一條數值路徑,以序號值帶過
SXDBB 與讓它有趣的位元打包
每筆記錄的索引表是整個結構中最值得玩味的技術細節,而它位於 SXDBB 記錄中。直覺式編碼會把每個欄位的項目索引儲存為 16 位元字。Excel 不這麼做。它把每個欄位的索引精確打包成足以定址該欄位項目的位元數,一個都不多。寬度是 ceil(log2(itemCount + 1)) 位元。+ 1 很重要:多出的值是哨兵,表示「空白,這筆記錄在此欄位沒有值」,因此有三個不重複項目的欄位必須表示四種狀態,所以需要兩個位元,而不是三個項目看起來會暗示的一個位元。完全沒有項目的欄位則貢獻零個位元,並在打包時完全跳過
一筆記錄的位元會跨所有欄位串接,然後下一筆記錄從新的位元組邊界開始。記錄是位元組對齊的,不是端到端做位元打包,這讓隨機存取資料表變得可行,代價是每列要多一些填充位元。位元組內的打包採最低有效位元優先。只要接受這兩條規則,編碼器就是單純的位元泵,解碼器則是它的鏡像
// Width of one field's index in the SXDBB stream.
// citmTotal distinct items need ceil(log2(citmTotal + 1)) bits,
// the +1 reserving a "blank" sentinel value.
function BitsForFieldItems(itemCount: Integer): Integer;
var
capacity: Integer;
begin
Result := 0;
if itemCount <= 0 then
Exit; // empty field contributes zero bits
Result := 1;
capacity := 2;
while capacity < itemCount + 1 do
begin
Inc(Result);
capacity := capacity * 2;
end;
end;
無法忽略這個細節,原因是單一 BIFF 記錄只有 8224 位元組的上限。格式裡的每一筆記錄,包括樞紐分析記錄在內,都必須把負載限制在最多 8224 位元組,而一個有數千列來源資料的繁忙樞紐分析快取,會在還沒把每列都輸出完之前就先撞上這條限制。所以索引表必須拆開。HotXLS 把單一 SXDBB 主體限制在 8220 位元組,也就是 8224 記錄限制扣掉 4 位元組的型態與長度標頭,再除以一筆打包記錄的位元組寬度,算出能放下多少完整列,然後依列數輸出多筆續接的 SXDBB 記錄。每筆續接記錄都在記錄邊界上乾淨重啟,所以沒有任何一列會被切到兩筆記錄之間。知道每筆記錄位元寬度的讀取器,可以像處理連續位元陣列一樣依序穿過每一個 SXDBB
檢視版面:主體用 SXLI,頁面用 SXPI
快取建好之後,資料表檢視就是後半段。它的核心是軸線項目,也就是樞紐分析主體中的那些列,列舉出資料表畫出的每一種列欄位值與欄欄位值組合。這些內容記在 SXLI 記錄中,記錄型態 0x00B5,描述於 [MS-XLS] §2.4.275。單一 SXLI 可以容納很多列,一樣會一直寫到 8224 位元組上限逼出新記錄為止,而且它使用一個小小的壓縮技巧:每一列只儲存和上一列的差異,以公共前綴計數表示,所以深度巢狀的軸不需要在每一列重複外層欄位值。總計列與任何記錄中的第一列都會把該前綴計數重設為零,因此讀取器永遠不需要跨越記錄邊界往回看就能重建一列
頁面軸,也就是座落在樞紐分析表上方的篩選下拉,是另一筆獨立記錄。SXPI,記錄型態 0x00B6,[MS-XLS] §2.4.276,為每個頁面欄位帶著一筆 10 位元組的項目:樞紐分析欄位索引 isxvd、選定的快取項目 iCache、位置字 ipos,以及舊式物件 ID objId。iCache 值最值得注意。顯示「(全部)」而且不做任何篩選的頁面欄位,儲存的是哨兵值 0x7FFD,而不是真實的項目索引。以程式建置的樞紐分析表開啟時,每個頁面欄位一開始都設成「(全部)」,直到呼叫者預先選定一個項目,這時那個項目的快取索引會取代哨兵值,Excel 開啟時就已經套用了篩選器。這些記錄旁邊還有描述個別欄位與格式設定的支援記錄,包含用於欄位檢視定義的 SXVD 和 SXVDEx、用於排序每個軸之欄位索引清單的 SXIVD,以及用於數字格式設定的 SXFormat,每一個都回頭索引到主體列所引用的同一個快取
二合一寫入器:原始 blob 與型態化模型
HotXLS 保留兩條完全獨立的樞紐分析表寫入路徑,有明確的結構理由,這直接來自對保真度的要求。當從磁碟讀進活頁簿時,裡面的樞紐分析記錄是由 Excel 或其他產生器寫出的,它們可能用了工作表寫入器無法完整模擬的記錄變體、排序怪癖或延伸記錄。對這些位元組,唯一安全的做法就是原封不動還回去。所以從檔案匯入的樞紐分析表會被標記為 FromRawBlobs = True,儲存時寫入器會逐位元組重播保留的記錄 blob。沒有內容會被重新產生,沒有內容會被重新解釋,透過開啟與儲存做來回轉換時,位元組也能保持穩定
程式建置的樞紐分析表則剛好相反。這裡沒有原始位元組可保留,只有型態化物件模型:帶有欄位與項目清單的 TXLSPivotCache,以及帶有軸分配的 TXLSPivotTable。這個資料表會被標記為 FromRawBlobs = False,而寫入器會用較硬的方式序列化它,輸出新的 BOF = 0x0006 快取子串流、從型態化模型持有的項目索引中打包 SXDBB 索引表,並依軸設定配置 SXLI 和 SXPI 記錄。這個旗標正是讓兩種型態得以共存於同一個活頁簿的關鍵。沒有它,單一寫入器就得不是放棄讀入資料表的保真度,就是拒絕產生新資料表。讀入資料表所帶的任何產生器特定延伸記錄,都會保留為支援記錄,透過資料表的 SupplementalRecords 清單連進來,所以透過型態化模型檢視的資料表,不會遺失模型沒有描述的部分
以程式碼建置樞紐分析表
上面所有機制都包在一個呼叫後面。AddPivotTable 取得 A1 標記法中的來源範圍、資料表左上角錨定的目標儲存格,以及名稱。它解析範圍、掃描範圍以推斷欄位型別並建置快取,如果另一個資料表已經繫結到相同範圍,就重用既有快取,然後回傳一個型態化的 TXLSPivotTable,每個來源欄位一個欄位,而每個欄位一開始都在軸外。接著您把欄位放到各個軸上並選擇彙總方式。上述特徵全部都在儲存時為您產生,包含快取、SXDBB 打包,以及檢視記錄
uses
lxHandle, lxPivot;
var
Book : TXLSWorkbook;
Sheet: IXLSWorkSheet;
Pivot: TXLSPivotTable;
begin
Book := TXLSWorkbook.Create;
try
Book.Open('Sales.xls');
Sheet := Book.Sheets[1];
// Source A1:E500 on 'Data'; anchor the pivot at row 3, col 1.
Pivot := Sheet.AddPivotTable('Data!$A$1:$E$500', 3, 1, 'SalesByRegion');
if Pivot <> nil then
begin
Pivot.AddRowField('Region');
Pivot.AddColumnField('Quarter');
Pivot.AddDataFieldByName('Revenue', xlpaSum);
end;
Book.SaveAs('Sales-Pivot.xls');
finally
Book.Free;
end;
end;
來源範圍的第一列會被讀成命名快取欄位的標頭,所以 AddRowField('Region') 是依標頭文字比對欄位,而不是依位置。因為傳回的資料表是 FromRawBlobs = False 的型態化模型,所以寫入器會走從零開始的路徑:它建置一個自足快取,不依賴來源範圍在重新整理時仍然存在,這正是當樞紐分析表要送給可能移動或刪除底層資料的收件者時,您會想要的特性
讀取並協調您沒有產生的檔案中的樞紐分析與快取記錄,包括原始 blob 保留路徑,請參考活頁簿稽核與轉換工作台逐步說明。當來源範圍達到數萬列,且 SXDBB 串流跨越許多續接記錄時,大型活頁簿效能筆記中的技巧可避免快取建置主導您的執行時間。兩者都與 Delphi 和 C++Builder 的 HotXLS 試算表元件中隨附的樞紐分析寫入器相輔相成,並與本部落格其他地方介紹的儲存格、公式、圖表和格式化 API 搭配