Bài viết kỹ thuật

Framing record PivotCache BIFF với HotXLS trong Delphi

Substream PivotCache của BIFF lưu bộ dataset cache của một PivotTable tách khỏi view hiển thị nó, và HotXLS đọc ghi substream đó bằng cách soi vào thân record thay vì tin vào số hiệu record. Sự phân biệt này là cả câu chuyện: cùng một số hiệu record mang hai body layout không tương thích tùy writer nào đã tạo ra file, nên reader quyết định framing từ thân record đầu tiên nó nhìn thấy

Bạn đụng tầng này ngay khoảnh khắc một PivotTable phải sống sót qua một round trip. Một pivot view không kèm cache chỉ là cái vỏ, và Excel sẽ dựng lại cache từ source range khi mở file — điều ổn cho tới khi source range đã mất, dữ liệu được paste từ một query, hoặc workbook là một bản close lưu trữ mà không được phép đổi khi ai đó mở nó

Hai cấu trúc, hai chỗ trong file

Dữ liệu cache và định nghĩa cache nằm ở những phần khác nhau của workbook, và việc gộp chúng làm một là điều đầu tiên phải tránh. Các cached record lập thành substream riêng, được [MS-XLS] §2.1.7.12 cho là PIVOTCACHE = SXDB SXDBEx *SXFORMULA *FDB *DBB EOF. Hãy để ý cái gì vắng mặt: không có BOF nào ở đầu production đó

Định nghĩa nằm trong workbook globals thay vào đó, dạng PIVOTCACHEDEFINITION = SXStreamID SXVS [SXSRC] [SXADDLCACHE] (§2.1.7.20.3), đặt sau các formatting record và trước BoundSheet cùng Country. Một cache đơn lẻ vì thế được mô tả ở hai chỗ cách nhau hàng trăm record, và liên kết giữa chúng là một stream identifier phải khớp nhau ở ba vị trí cùng lúc

Framing PivotCache của HotXLS trong BIFF8: PIVOTCACHEDEFINITION với SXStreamID của nó nằm trong workbook globals sau formatting và trước BoundSheet, trong khi cached record sống trong một stream dưới storage _SX_DB_CUR với tên là hex in hoa bốn chữ số, chứa các record SXDB, SXDBEx, SXFORMULA, FDB và DBB mà không có BOF, và SXStreamID.idStm, field idstm của SXDB cùng tên stream phải khớp nhau
Một pivot cache đơn lẻ được mô tả ở hai chỗ cách nhau hàng trăm record, nối với nhau bằng một stream identifier phải khớp đồng thời ở globals, header SXDB và tên substream

Mỗi cache thuộc về một stream dưới _SX_DB_CUR mà tên của nó là dạng hex in hoa bốn chữ số của identifier. SXStreamID.idStm, field idstm lặp lại trong header SXDB, và tên stream đó phải khớp cả ba. Khi cấp một identifier mới, hãy reserve trước mọi số đã đọc từ file, nếu không một cache mới có thể chiếm một số thuộc về cache cũ hơn mà reader chưa đi tới

Còn một identifier nữa bắt người ta. Giá trị iCache trong một pivot view là vị trí zero-based của SXStreamID tương ứng trong dãy global, không phải cache identifier để bạn tùy chọn. Khi ghi, nó phải được map từ cache object sang vị trí output thật của nó, và các view hiện có phải được đánh số lại theo — nếu không, nâng cấp một cache sẽ lặng lẽ trỏ một view sang cache khác

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;     // tin vào cached record
    Cache.SaveData := True;

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

    Cache.SetRecordCount(0);          // clear, rồi canh size record grid
    Cache.SetRecordCount(500);
    Book.StorePivotCaches;
  finally
    Book.Free;
  end;
end;

Cặp SetRecordCount đôi không phải mê tín. RecordCount là một phép ghi property thuần không cấp phát, và đường tăng trưởng nội bộ chỉ khởi tạo các row mới thêm vào, nên một cache mà count được set qua đường header có thể kết thúc với một index grid dài 0. Phép ghi vào RecordIndices khi đó bị vứt bỏ mà không error. Set count về 0 rồi lên lại thiết lập lại grid, và việc đó phải diễn ra sau khi mọi field đã được thêm, vì row width đến từ field count

Vì sao record number không thể nói cho bạn body layout?

Vì record number và body layout đổi ở những thời điểm khác nhau, nên ánh xạ giữa chúng không phải một hàm số. Một số trong bộ legacy chỉ xuất hiện trong file từ các writer cũ, biến nó thành một tín hiệu đáng tin theo một chiều. Số khác thì thật sự mơ hồ: nó hiện diện cả trong file đúng lẫn trong một dải version trung gian từng dùng số mới với body layout cũ

Vì vậy framing phải được quyết từ thân record, và một lần cho cả cache substream chứ không phải theo từng record. HotXLS chốt dialect từ độ dài của record SXDBB đầu tiên trong mỗi substream. Ở framing theo specification, một SXDBB giữ đúng một cache record, nên độ dài của nó bằng đúng một row width. Ở framing packed cũ hơn, record đầu chứa bao nhiêu row vừa hết, nên với bất kỳ cache nào có nhiều hơn một row, nó ít nhất là hai row width. Phép so sánh là dứt khoát bất cứ khi nào hai dự đoán khác nhau

Cơ chế chốt framing SXDBB của HotXLS: một record number mang hai body layout không tương thích, nên reader đem độ dài record SXDBB đầu tiên so với row width, một row width chốt dialect specification còn hai row width trở lên chốt framing packed legacy, hòa thì lấy cách đọc specification, và dialect chốt một lần mỗi cache substream chứ không phải từng record
Record number không quyết định được body layout vì hai bên đổi ở thời điểm khác nhau, nên HotXLS chốt dialect một lần mỗi substream từ độ dài SXDBB đầu tiên và lấy cách đọc specification khi hòa

Khi hai dự đoán trùng nhau, reader lấy cách đọc specification, theo nguyên tắc file do Excel viết nhiều hơn file do một bản build trung gian viết. Điểm mù đó hẹp theo thiết kế, và khi nó có xảy ra, bản thân file vẫn được replay từng byte nguyên vẹn. Chỉ có các typed index expose cho caller là chịu ảnh hưởng

Index width nằm trong một record khác

SXDBB (§2.4.276) mang một index cho mỗi cache field có cờ distinct-value, theo thứ tự field, còn width của từng index lại được quyết ở nơi khác: record field SXFDB tương ứng (§2.4.283) khai một cờ short-items, và cờ đó nói index chiếm hai byte hay một byte. Hai record, một hợp đồng ngầm định, và đúng một câu trong specification nối chúng lại

Sự móc nối đó chính là chỗ một bản encode tự chế đi lệch. Một writer HotXLS trước đây pack từng field vào số bit tối thiểu, pad tới ranh giới byte giữa các row — bảo vệ được khi đứng riêng, và mâu thuẫn trực tiếp với width mà chính writer đó vừa khai trong SXFDB. Một field có ba giá trị distinct được mô tả rộng một byte trong record này và chiếm hai bit trong record kia. Fix không phải sửa phép tính mà là tách quyết định width vào một hàm duy nhất mà cả hai emitter đều gọi, để hai record không còn cơ hội trôi dạt nhau. Cùng một hạng defect được mô tả trong BIFF record length declaration drift, nơi một size khai báo và một phần thân thật sự chia tay nhau

Hệ quả của việc không đọc những record này chút nào đáng được nói rõ, vì dễ đánh giá thấp. Khi reader bỏ qua các record index, mọi cache load từ file đều báo index 0 cho mọi field của mọi row — tức là mọi row đều trỏ vào giá trị đầu tiên của mọi field. Đó không chỉ là introspection suy giảm: đường evaluate pivot và đường fill cache ra cell cùng tiêu thụ cái grid đó. Và round-trip test không phát hiện nổi, vì một cache vẫn đang raw replay được ghi lại từ byte gốc của nó

// Các provenance flag cho biết bạn đang cầm thứ gì và thứ nào được phép ghi lại
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);
  // Emit lại chỉ lossless khi mọi record đều có model ở đây
  if Cache.CanUpgradeFraming then
    Writeln('safe to rewrite with the current emitters');
end;

Khi nào ghi lại một cache là lossless?

Chỉ khi ba điều kiện cùng thỏa, và CanUpgradeFraming là property duy nhất trả lời câu hỏi đó. Cache phải vẫn đang raw replay, substream phải nằm trong một trong những framing mà library này từng ghi sai trước đây, và reader phải đã dựng một typed model hoàn chỉnh cho mọi record trong đó. Một cache do Excel viết không bao giờ đủ điều kiện, vì substream của nó mang những record HotXLS không có model, và emit lại từ model sẽ đánh rơi chúng

Phép test tính đầy đủ ngặt hơn vẻ ngoài. Một record mà reader chỉ giữ dưới dạng byte mờ đục đánh dấu model chưa hoàn chỉnh. Một count của các formula record mà emitter không tái hiện được cũng vậy, vì emit lại sẽ đổi một khai báo của vài formula record thành khai báo của không có gì, và một giá trị trong file không tái hiện được tương đương một record không tái hiện được

Sự bảo thủ có chủ ý xuyên suốt cả writer. Index bị clamp vào khoảng hợp lệ thay vì encode thành một sentinel ngoài băng tần, vì specification định nghĩa một index vào dãy distinct-value và chẳng gì khác, còn một cell rỗng tự nó là một giá trị trong dãy đó. Một cache record body vượt trần BIFF record không được ghi, chuyện đòi hàng nghìn cache field và dù sao cũng không với tới được trong giới hạn cột BIFF8; fallback là Excel refresh lại từ source range — hành vi được định nghĩa chứ không phải file hỏng

Date mang theo dependency liên record cuối cùng. Phép chuyển serial ra date phụ thuộc date system của workbook, và record emitter không nhìn thấy workbook, nên lựa chọn base date được truyền vào như một tham số mặc định theo hệ 1900 và do đường save cấp workbook cung cấp. Dưới hệ 1900, serial number là giá trị trực tiếp; hệ 1904 lệch 1462 ngày. Phần xử lý date serial rộng hơn nằm trong date serial, hệ 1904 và number format

Nếu bạn làm việc ở tầng view thay vì tầng cache, các record mô tả pivot nhìn thấy được được nói trong bộ record PivotTable BIFF8, còn hành vi phía tính toán nằm trong calculated field, calculated item và refresh. Cả ba tầng đều có trong HotXLS Delphi spreadsheet component, và chính điều đó làm nên khả năng load một workbook legacy, soi xem cache của nó thực sự chứa gì, và quyết định ghi lại nó có an toàn trước khi bạn làm