技術文章

HotXLS BIFF8 Selection 記錄與窗格捲動位置

HotXLS 透過一套窗格感知的 API,在 TXLSWorksheet 與 TXLSXWorksheet 上以同樣方式管理工作表選取與捲動位置:SelectAreas、GetSelectedAreas、ScrollWindow 與 TryGetWindowScroll。對傳統的 .xls 檔,HotXLS 寫出每筆至多 1369 個區域的 BIFF8 Selection 記錄(0x001D),把邏輯窗格名稱轉成格式定義的窗格位元組,並讓每個捲動軸留在 Excel 預期所在的 Window2 或 Pane 記錄上

問題通常出現在對帳或稽核工具裡。工具打開一份帳冊匯出,找出所有與來源系統不符的儲存格,然後在凍結表頭列的狀態下連同這些已選取的儲存格一起存檔,讓審閱者一開檔就落在差異上,而不是自己捲半天找。四十個差異時一切正常。月底檔案有三千個,而單筆 Selection 記錄不可能塞下 3000 個區域:它的本文需要 18,009 位元組,超過 BIFF8 單筆記錄承載量的兩倍以上。捲動位置有類似的陷阱。在凍結窗格的工作表上,「使用者正在看哪裡」是四個窗格共享兩個列位置與兩個欄位置,不是一個座標

大量選取為什麼需要多筆 Selection 記錄?

因為 BIFF8 記錄本文的上限是 8224 位元組,而每個選取區域固定花費六位元組。[MS-XLS] §2.4.248 把 Selection 記錄定義成 9 位元組的固定部分(窗格位元組、作用中儲存格的 rwAct 與 colAct、作用中區域的 irefAct、區域計數 cref),後面跟著 cref 個 RefU 結構,每個裝兩個 16 位元的列與兩個 8 位元的欄。放得下的最大數量是 (8224 − 9) / 6 無條件捨去,即 1369,得到的本文是 8223 位元組,剛好比上限少一位元組。TXLSWorksheet.StoreSelectionGroup 把這個常數用作 MaxAreasPerRecord,更大的群組則以同一窗格連續的 Selection 記錄寫出,一次 1369 個區域

咬人的細節是 irefAct。每個分塊重複同樣的作用列、作用欄與作用區域索引,而 irefAct 索引的是所有分塊彙總後的序列,不是承載它的這筆記錄內部的區域。一個剛好超限一個區域的選取能讓這點具體起來:1370 個區域且最後一個作用中,會變成兩筆記錄,第一筆 cref 是 1369、第二筆 cref 是 1,而兩筆都帶著 irefAct 1369。這個值大於第二筆記錄自己的區域數。對每筆記錄拿 irefAct 去對 cref 檢查的讀取器會拒收一個合法檔案,而每筆記錄都整個取代自身狀態的讀取器則會丟掉前 1369 個區域。HotXLS 的讀取器把同一窗格的連續記錄附加成一個群組,要求每個分塊在作用中儲存格與索引上一致,並把範圍檢查延到工作表的 EOF 記錄才跑,也就是完整序列已知之後。因此窗格優先的 SelectAreas 多載沒有 1369 區域的天花板。它先驗證每個 A1 參照與作用中索引,才取得工作表寫入鎖,任何格式不對就回傳 False、先前的選取保持不變

HotXLS 為什麼把大量工作表選取寫成多筆 BIFF8 Selection 記錄:8,224 位元組的本文上限裝得下 9 個固定位元組加 1369 個六位元組的 RefU 區域,所以 3000 個區域變成同窗格的 1369、1369 與 262 三筆記錄,而 irefAct 索引彙總序列,1370 個區域且最後一個作用中時兩筆都帶 irefAct 1369
每個分塊重複同樣的作用中儲存格與索引,HotXLS 讀取器把同窗格的連續記錄附加成一個群組,範圍檢查延到 EOF 記錄、完整序列已知之後才跑
var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  Diffs: TXLSSelectedAreas;
  I: Integer;
begin
  Book := TXLSWorkbook.Create;
  try
    Sheet := Book.Sheets.Add;
    Sheet.FreezePanes(1, 1);           // 表頭列與 A 欄保持不動

    SetLength(Diffs, 3000);
    for I := 0 to High(Diffs) do
      Diffs[I] := Format('C%d', [I + 2]);

    // 凍結會重設已儲存的選取,所以先凍結再選取。
    // 3000 個區域存成三筆 Selection 記錄:1369 + 1369 + 262
    if not Sheet.SelectAreas(xlspBottomRight, Diffs, 0) then
      raise Exception.Create('Selection rejected');

    Book.SaveAs('reconciliation.xls');
  finally
    Book.Free;
  end;
end;

Selection 記錄用哪個窗格位元組?

Selection 記錄以格式定義的數值代碼識別窗格:0 是右下、1 是右上、2 是左下、3 是左上。公開列舉 TXLSPanePosition 依閱讀順序宣告:xlspTopLeft、xlspTopRight、xlspBottomLeft、xlspBottomRight,所以 Ord(xlspTopLeft) 是 0,而那是檔案裡的右下窗格。把列舉直接硬轉成窗格位元組,會把每個左上選取寫到右下窗格上,而且不會有任何錯誤。每個窗格感知的 HotXLS 進入點都改用明確的 case 敘述轉換列舉,呼叫端完全不用碰數值代碼。窗格存在性也會檢查:右上窗格只在有垂直分割時存在,左下只在有水平分割時,右下則兩者都要有。對目前分割或凍結幾何沒有的窗格,SelectAreas 回傳 False,GetSelectedAreas 回傳空陣列且 ActiveAreaIndex 設為 -1,不會替活頁簿建立任何窗格、選取物件或儲存格

HotXLS 如何把 TXLSPanePosition 映射到 BIFF8 Selection 窗格位元組:列舉依閱讀順序宣告所以 Ord(xlspTopLeft) 是 0,而檔案定義 0 為右下、1 為右上、2 為左下、3 為左上,所以每個窗格感知的進入點都透過明確的 case 敘述轉換
把列舉直接硬轉成窗格位元組,會把每個左上選取寫到右下窗格上,所以 HotXLS 在寫入前還會對照目前的分割或凍結幾何檢查窗格存在性

每個窗格的捲動位置住在哪裡?

每個窗格的捲動位置拆在兩筆記錄上,因為四個窗格只共享兩個列位置與兩個欄位置。在傳統活頁簿裡,上方窗格的第一個可見列與左側窗格的第一個可見欄是 Window2.rwTop 與 Window2.colLeft,而下方窗格的列與右側窗格的欄則是 Pane.rwTop 與 Pane.colLeft。所以 ScrollWindow(xlspTopRight, R, C) 寫的是 Window2.rwTop 與 Pane.colLeft,設定右上窗格的欄也會移動右下窗格,正如 Excel 裡兩者共享同一條水平捲軸。公開方法使用從 1 起算的列與欄編號。窗格不存在時回傳 False 並把兩個查詢輸出設為零,超出範圍的座標則在任何軸被改動之前就拒絕。這一切都不依賴檢視器怎麼畫格線。渲染控制項保留自己的 TopRow 與 LeftCol,如在自訂 VCL 表格中渲染活頁簿一文所述,那些是執行期狀態,不是存檔的內容

HotXLS 各窗格捲動軸的歸屬:四個窗格共享兩個列位置與兩個欄位置,上方的列與左側的欄是 Window2.rwTop 與 Window2.colLeft,下方的列與右側的欄是 Pane.rwTop 與 Pane.colLeft,而 ScrollWindow(xlspTopRight, 1, 6) 寫一個 Window2 欄位加一個 Pane 欄位,右下窗格跟著移動
XLSX 把同一份資料攤在 sheetView 與 pane 的 topLeftCell 屬性上,而把兩層併成一層,正是上方或左側捲動位置在載入時無聲消失的原因

XLSX 把同一份資料攤在兩個元素上:sheetView/@topLeftCell(ECMA-376 Part 1,§18.3.1.87)對應整個視窗,子元素 pane/@topLeftCell(§18.3.1.66)對應分割後的右下半部。兩個屬性可以同時存在。HotXLS 先把外層屬性讀進視窗層級欄位,再讓 pane 子元素只覆寫窗格層級欄位,寫回時兩者分開。把兩層併成一層,正是上方或左側捲動位置在載入時無聲消失的原因。工作表複製在兩個引擎裡都帶著這兩層。較舊的進入點維持原行為:傳統的 ScrollRow 與 ScrollColumn 屬性,以及以 0 為基底的 XLSX SetPaneScroll 與 GetPaneScroll。凍結與分割幾何本身則用工作表保護、版面設定與列印涵蓋的表層級設定來配置

var
  Row, Col: Integer;
begin
  Sheet.FreezePanes(1, 1);

  // 右下:下方列軸(Pane.rwTop)與右側欄軸(Pane.colLeft)
  Sheet.ScrollWindow(xlspBottomRight, 500, 3);

  // 右上共享右側欄軸,所以這行也把右下移到第 6 欄
  Sheet.ScrollWindow(xlspTopRight, 1, 6);

  if Sheet.TryGetWindowScroll(xlspBottomRight, Row, Col) then
    Memo1.Lines.Add(Format('Bottom-right starts at row %d, column %d', [Row, Col]));
    // 右下從第 500 列、第 6 欄開始
end;

Selection 記錄損壞時會怎樣?

Selection 記錄損壞時,HotXLS 把它當成不透明位元組保留、回報診斷碼 1304(xlsDiagnosticSelectionRecordInvalid),並在存檔時逐位元組原樣寫回原始本文。記錄加入其窗格的群組之前,讀取器按順序檢查:窗格位元組必須不大於 3。同一窗格的記錄在串流裡必須連續。9 個固定位元組必須齊全。cref 必須落在 1 到 1369 之間,且本文長度必須正好是 9 + cref × 6 位元組。群組裡每個分塊對作用中儲存格與 irefAct 必須一致,irefAct 不得設起符號位元,作用中欄必須在格線上,且任何區域不得上下界顛倒。單一實體記錄裡的問題按每筆記錄回報一次。只有彙總之後才浮現的矛盾,例如 irefAct 指向總區域數之外,或作用中儲存格落在被索引的區域外,則在 EOF 按每個群組回報一次。無效的群組對型別 API 隱形:GetSelectedAreas 對該窗格回傳空陣列與索引 -1,其他窗格照常運作

var
  I: Integer;
  D: TXLSDiagnostic;
begin
  if Book.Open('supplier-upload.xls') <> 1 then
    Exit;
  for I := 0 to Book.Diagnostics.Count - 1 do
  begin
    D := Book.Diagnostics[I];
    if D.Code = xlsDiagnosticSelectionRecordInvalid then
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

選取怎麼在插入列與欄之後存活?

選取能在結構編輯後存活,是因為插入或刪除整列整欄時,兩個引擎都透過一個共用的重新映射器,把所有已表示的窗格群組重映射一遍。存活的區域保持順序,作用中區域保持身分。作用中區域被刪除時,第一個存活的後繼者變成作用中,若後面沒有了則取最後一個存活的前驅者。所有區域都被刪除時,群組塌縮成刪除邊界上的一個儲存格,而不再落在所選區域內的作用中儲存格會移到該區域的左上角,所以索引與座標永遠不互相矛盾。這些限制是刻意的。無效的傳統群組會被重新映射器跳過,而不是被改寫成捏造的選取,所以它們的原始位元組照樣走完 round trip。編輯一個窗格只替換該窗格的記錄,其他窗格逐位元組不變。ODS 則完全沒有窗格選取狀態,因為 ODF 沒有可承載它的對等工作表檢視結構

如果您的應用程式寫出的 .xls 檔要給使用者打開瀏覽——不管是審閱被標記的儲存格、從上次停下的地方繼續,還是分享一個凍結的儀表板——窗格感知的選取與捲動 API 都是 HotXLS Delphi 試算表元件的一部分,對 XLS 與 XLSX 行為一致