技術文章

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

對帳工具找出 3,000 個與帳冊不符的儲存格,希望存出來的 .xls 打開時,每一格都處於選取狀態、標題列還凍結著。紙面上這是一筆 Selection 記錄;以位元組論是 9 + 3,000 × 6 = 18,009,而 BIFF8 記錄 body 在 8,224 就到頂。同一本活頁簿還帶出第二個聽起來瑣碎、其實不然的問題:在 B2 凍結的工作表上,"the scroll position" 是四個窗格共用兩個列偏移與兩個欄偏移,而這四個數字住在兩筆不同的記錄裡

兩個問題都是格式算術:選取要切成讀取端得重新縫合的記錄;窗格用一個不按閱讀順序的代碼命名;每條捲動軸各落在 Excel 實際去讀它的那筆記錄

3,000 個選取區域為什麼需要三筆 Selection 記錄

[MS-XLS] §2.4.248 把 Selection 記錄(0x001D)排成 9 位元組固定部份接區域清單。固定部份有窗格位元組、作用中儲存格的 rwAct 與 colAct、作用中區域索引 irefAct,以及區域計數 cref。每個區域是帶兩個 16 位元列、兩個 8 位元欄的 RefU 結構。裝得下的最大數量是 (8,224 − 9) / 6 取整數,得 1,369 個區域、8,223 位元組的 body,距上限正好一位元組

HotXLS 以 1,369 為單筆記錄上限。TXLSWorksheet 存的選取群組超過這個數時,為同一窗格連寫多筆 Selection 記錄、每筆 1,369 個區域,且每筆重複相同的窗格位元組、作用中儲存格與作用中索引。3,000 格的對帳選取於是成為三筆記錄,分別裝 1,369、1,369 與 262 個區域。呼叫端看不見這個切分——遞進來的就是一個 A1 參照陣列

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]);

    // FreezePanes 會清掉已存選取,所以凍結之後再選。
    // 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;

這裡呼叫順序有講究。FreezePanes 重建窗格版面、清掉工作表帶著的每一份窗格選取,更早的選取到存檔時早就不在了。未凍結的工作表上右下窗格還不存在,同一個 SelectAreas 呼叫會直接回 False。先凍結,再選取

選取跨多筆記錄時,irefAct 指向哪裡?

切分過的選取裡,irefAct 為跨所有分塊聚合後的區域序列編索引,每個分塊帶同一個值。超限一個區域的選取把這件事變具體:1,370 個區域、最後一個作用中,成為兩筆記錄,第一筆 cref 1,369、第二筆 cref 1,兩筆都帶 irefAct 1,369。在第二筆記錄裡,這個索引大於該記錄自己的區域數。一次只讀一筆記錄的話,這看起來像壞了,而兩種讀取端正是在這裡絆倒

  • 在每筆記錄內部拿 irefAct 對 cref 做檢查的讀取端,會在第二個分塊處退回一份完全合法的檔案
  • 每收到一筆 Selection 記錄就整個替換選取狀態的讀取端,只留最後一個分塊,丟掉前 1,369 個區域

HotXLS 的讀取端把同窗格的連續記錄追加進同一組,並要求每個分塊在作用中列、作用中欄與 irefAct 上一致。irefAct 的範圍檢查、以及作用中儲存格確實落在它指向的區域內的檢查,都等到工作表的 EOF 記錄抵達、完整序列已知時才跑。提早檢查程式碼更簡單,但對任何超過 1,369 個區域的選取都是錯的答案

HotXLS 為何把大型工作表選取寫成多筆 BIFF8 Selection 記錄:8,224 位元組的 body 上限裝得下 9 個固定位元組加 1369 個六位元組 RefU 區域,3000 個區域因此成為同窗格的三筆記錄(1369、1369、262);irefAct 為聚合後的序列編索引,1370 個區域、最後一個作用中時,兩筆記錄都帶 irefAct 1369
每個分塊重複同一個作用中儲存格與索引;HotXLS 讀取端把同窗格的連續記錄併成一組,範圍檢查只在 EOF 記錄、完整序列已知時才跑

寫入端方面,窗格優先的 SelectAreas 多載自身沒有 1,369 區域上限。它在碰工作表之前先解析每個參照:$ 記號接受並剝除、C9:A2 這種反向範圍正規化成 A2:C9、空或解析不了的條目讓整個呼叫失敗。作用中索引必須落在陣列內且不超過 32,767——irefAct 是帶號 16 位元欄位。任何失敗都回 False,先前的選取原樣不動

窗格代碼不按閱讀順序

Selection 記錄以格式定義的數值代碼命名窗格:0 右下、1 右上、2 左下、3 左上。公開列舉 TXLSPanePosition 卻按閱讀順序宣告:xlspTopLeft、xlspTopRight、xlspBottomLeft、xlspBottomRight。於是 Ord(xlspTopLeft) 是 0,而檔案裡的 0 意味右下。把 enum 直接強轉進窗格位元組的寫入端,會把每個左上選取放到右下窗格,檔案在結構上照樣合法——兩個都是合法的窗格代碼

HotXLS 裡每個窗格感知進入點都經明確的 case 敘述轉換 enum,應用程式碼永遠不必碰數值代碼。窗格存在性也會對著目前的凍結或分割幾何檢查:左上窗格永遠存在;右上需要垂直分割,左下需要水平分割,右下兩者都要。工作表沒有的窗格,SelectAreas 回 False、GetSelectedAreas 回空陣列且 ActiveAreaIndex 為 -1,兩個呼叫沿途都不會創造窗格、選取或儲存格

HotXLS 如何把 TXLSPanePosition 映射到 BIFF8 Selection 的窗格位元組:enum 按閱讀順序宣告,Ord(xlspTopLeft) 是 0,而檔案格式定義 0 為右下、1 為右上、2 為左下、3 為左上,因此每個窗格感知進入點都經明確的 case 敘述轉換
把 enum 直接強轉進窗格位元組,每個左上選取都會被寫到右下窗格;HotXLS 因此還會在寫入前,對著目前的分割或凍結幾何檢查窗格是否存在

工作表還保留較舊的雙參數 SelectAreas(Areas, ActiveAreaIndex):寫進當前作用中的窗格、回傳 Integer 狀態。在凍結窗格的工作表上,這常常是對一份不是你自己設定的狀態下賭注。凡是在凍結或分割工作表上跑的東西,優先用收 TXLSPanePosition 的多載——窗格於是成為呼叫的一部分,False 的結果明白告訴你幾何與請求不一致。讀回結果是同一個形狀:

var
  Areas: TXLSSelectedAreas;
  Active: Integer;
begin
  Areas := Sheet.GetSelectedAreas(xlspBottomRight, Active);
  if Active < 0 then
    Memo1.Lines.Add('No usable selection on the bottom-right pane')
  else
    Memo1.Lines.Add(Format('%d areas, active area %s',
      [Length(Areas), Areas[Active]]));
end;

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

每個窗格的捲動位置拆在兩筆記錄裡,因為四個窗格只共用兩個列位置與兩個欄位置。傳統活頁簿裡,上排窗格的第一個可見列與左欄窗格的第一個可見欄是 Window2.rwTop 與 Window2.colLeft;下排窗格的列與右欄窗格的欄是 Pane.rwTop 與 Pane.colLeft。所以右上從 Window2 拿列、從 Pane 拿欄,左下正好相反

ScrollWindow 與 TryGetWindowScroll 精確跟隨這個版面,用一基起算的列與欄號。ScrollWindow(xlspTopRight, R, C) 寫 Window2.rwTop 與 Pane.colLeft——設右上窗格的欄,同時也移動右下窗格。這與 Excel 一致:右側兩個窗格共用一條水平捲軸。窗格缺席時 ScrollWindow 回 False、TryGetWindowScroll 把兩個輸出設為零。超出範圍的列或欄在任何一軸動之前就被拒,壞欄位不會留下一半套用的列

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;
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)管分割的右下半部。兩個屬性可以同時在場。只留其中一個的載入器,會丟掉每份碰到的凍結工作表的上排列或左側欄。TXLSXWorksheet 先把外層屬性讀進視窗層級欄位,再讓 pane 子元素只覆寫窗格層級欄位,寫回時兩層分開。ScrollWindow 與 TryGetWindowScroll 在 TXLSXWorksheet 上簽名與意義相同,複製工作表在兩種格式下都帶著兩層

只需要一對軸的程式碼,較窄的屬性仍在:傳統工作表上的 ScrollRow 與 ScrollColumn,以及 XLSX 上給右下窗格、零基起算的 SetPaneScroll 與 GetPaneScroll。渲染控制項保有自己的 TopRow 與 LeftCol——在自訂 VCL 表格中渲染活頁簿一文講的正是這個——那些執行期狀態與存檔內容是兩回事。凍結與分割幾何本身,用工作表保護、版面設定與列印涵蓋的表層級設定

損壞的 Selection 記錄原樣保留

Selection 記錄畸形時,HotXLS 把它的 body 當不透明位元組保留、以診斷碼 1304(xlsDiagnosticSelectionRecordInvalid)記一條警告,存檔時原樣寫回。來路不明的匯出器或手搓 BIFF 寫入端的檔案可能帶這種記錄。記錄加入窗格的組之前,讀取端檢查:窗格位元組不大於 3、同窗格記錄連續、cref 在 1 到 1,369 之間且 body 恰為 9 + cref × 6 位元組、各分塊在作用中儲存格與去符號位的 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 是你自己的 TStrings;HotXLS 只填 Diagnostics
      Log.Add(Format('%s: record $%.4x kept opaque (%s)',
        [D.SheetName, D.RecordId, D.Message]));
  end;
end;

另一個選項是修記錄——鉗制 irefAct、丟掉反向區域。保留原樣是更安全的預設:修好的選取沒有人選過,而原始位元組已經不在了。診斷訊息點名失敗的檢查,例如 "the area count does not match the record length" 或 "records for the same pane are not contiguous",這個代碼的 RecordId 恆為 $001D

插入列與欄會重映射每個窗格的選取

插入或刪除整列整欄時,兩套引擎(傳統與 XLSX)都用同一條共用常式重映射每個窗格的選取。活下來的區域保持順序、隨覆蓋的儲存格平移,作用中區域保持身分。作用中區域被刪,其後第一個存活區域接棒;後面沒有了,就由前面最後一個存活區域接。全部刪光,群組塌縮成刪除邊界上的一個儲存格。不再落在所選區域內的作用中儲存格,搬到該區域的左上角——存出來的檔案裡,索引與座標因此永遠不會互相矛盾

載入時被標為無效的群組,重映射器不去動它們,編輯之後原始位元組照樣原樣往返。改某個窗格的選取只重寫該窗格的記錄,其他窗格的記錄逐位元組不變。ODS 檔案完全不帶窗格選取狀態——OpenDocument 沒有對應的逐窗格工作表視圖結構;選取與窗格捲動位置適用於 XLS 與 XLSX

如果你的應用程式存出的活頁簿,要給人回來複查標記的儲存格、接續上次進度、或讀一份凍結的儀表板,窗格感知的選取與捲動呼叫隨 HotXLS Delphi 試算表元件出貨,.xls 與 .xlsx 輸出行為一致