技術文章

Delphi 中的 BIFF SupBook 與 XTI 外部連結分類

開啟一個舊 xls、再存一次,原本呼叫已登錄分析程式庫的增益集公式現在指向活頁簿內部一個空參考。HotXLS 把這個無聲的破壞追溯到一個錯誤假設:以為 BIFF SupBook 記錄不是自己就是外部檔案。[MS-XLS] 定義了七種,不是兩種

為什麼儲存後的活頁簿會弄丟增益集連結?

因為分類測試做的是結構判斷,而不是型別判斷。傳統的捷徑是讀一筆 SupBook 記錄($01AE),檢查它是否帶著自我標記,若不是,就把後面的字串當成文件 URL。所有不屬於這兩者的記錄都落進預設分支,而預設分支幾乎總是「這是本活頁簿自己」。增益集支援連結、同表連結、未使用的槽位與截斷的記錄,最後全被貼上同一張錯標籤。過程中沒有任何東西拋出例外:記錄解析了、公式重新編譯了、檔案毫無警告地存好了,三週後才有人發現原本做匯率換算的那欄變成一排零。[MS-XLS] §2.4.271 描述的記錄可能是自我參考、同表參考、增益集函式容器、帶虛擬路徑與表名表的外部活頁簿、DDE 或 OLE 資料連結,或未使用的佔位符——外加第七種不在規格裡、但真實磁碟上存在的狀態:根本無法解析的記錄。修法不是更好的啟發式,而是乾脆不要有啟發式

SupBook 記錄能承載的七種類型

HotXLS 在 lxExternSheet.pas 裡把支援連結分類法宣告為封閉列舉,下游每個決策都按它分派。九個列舉值涵蓋七個類別,因為 DDE 與 OLE 的情況需要一個暫定狀態才能解析:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // 解析失敗,或殘留多餘位元組
    slkSelf,              // 本活頁簿
    slkSameSheet,         // U+0000 標記
    slkAddIn,             // 增益集函式容器
    slkExternalWorkbook,  // 虛擬路徑 + 表名表
    slkDde,               // 由 ExternName 旗標解析
    slkOle,               // 由 ExternName 旗標解析
    slkDdeOrOle,          // 兩者之一,暫未知是哪個
    slkUnused);           // 單一空格佔位符

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // 零基,與 ExternSheet.rgXTI 中儲存的一致
    ExternID    : Integer;   // 一基,內部慣例
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

分派由哨兵值驅動,不是由字串驅動。欄位值 $0401 標記自我記錄。表數為一配上 $3A01 標記增益集容器。只有落在 1 到 $00FF 範圍內的值才代表後面跟著編碼虛擬路徑,也只有這時 HotXLS 才真的解碼字串。不屬於這三種形狀的記錄一律保持 slkUnknown;即使開頭看起來合理,只要表名表沒有恰好耗盡記錄主體,就降回 slkUnknown

HotXLS 用來把 BIFF SupBook 記錄分進七種類型的哨兵驅動階梯:只對編碼路徑範圍內的值解碼字串,其餘落回未知類型而非預設分支
每個類型都由哨兵值而非字串測試到達;不符合任何形狀的記錄保持未知,不會落進那個意思是「本活頁簿」的預設分支

為什麼同表標記解碼後是空字串?

因為通用 BIFF 字串讀取器會毀掉分類所依賴的那個位元組。同表支援連結是個單字字串,唯一的字元是 U+0000,而 TXLSBlob.GetBiffString 把它交回成空的 WideString,與真正為空的路徑無法區分——而這正是自我參考啟發式會回答「是自己」的那種輸入。HotXLS 因此從記錄主體直接讀取原始第一碼位,而不是相信解碼後的值:

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // 壓縮,一位元組
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // 寬字元,兩位元組
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

留意壓縮與寬字元的分支。選項位元組位在距字串標頭固定偏移處,第一碼位按位元 0 決定是一位元組還是兩位元組,所以無條件按位元組讀取在多數檔案上可行,卻在本地化版本寫出的檔案上失敗——這是一個 bug 所能擁有最糟糕的分布。未使用佔位符用同樣方式捕捉,憑它字面上單一空格的內容;DDE 或 OLE 的情況則憑編碼路徑內嵌的 U+0003 分隔符

為什麼 HotXLS 從 BIFF SupBook 記錄主體讀取原始第一碼位而非解碼後的字串——因為通用字串讀取器會把同表 U+0000 標記摺成空值
同表標記是個字元為 U+0000 的單字字串,通用字串讀取器會把它摺成空值,只有選項位元組偏移處的原始碼位能保住它

為什麼 DDE 與 OLE 在 SupBook 階段分不開?

因為 SupBook 記錄不帶那幾個決定性的位元。它只告訴您連結是兩者之一;決定是哪個的 fOlefOleLink 旗標住在串流後面才到的 ExternName 記錄($0023)裡。HotXLS 在解析時記下 slkDdeOrOle,在 ParseExternalName 裡收窄;如果 ExternName 始終沒來,這個類型就永遠保持暫定——而這是正確的,因為檔案真的沒說。下游每個消費者都把那個暫定值當真值而不是缺值對待,所以沒有任何呼叫端需要發明平局裁決。在這裡猜「大概是 DDE」能換來更整潔的列舉,外加一類沒人追得回去的錯誤答案:

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

XTI 索引在磁碟上零基、在內部一基

HotXLS 恰好在一個地方做差一轉換:語彙進入內部語法樹的那一刻,其他地方一律不做。PtgNameX.ixti([MS-XLS] §2.5.198.85)是 ExternSheet 記錄($0017,§2.4.106)rgXTI 陣列的零基索引,而程式庫內部的 ExternID 慣例是一基的,零保留給「無外部表」。BIFF8 讀取路徑在解碼 tNameX 語彙時做 FExternID := wValue + 1,寫入路徑輸出 StoreExternID - 1,原始語彙檢視與磁碟語義原封不動。這裡弄錯異常難抓:外部定義名稱會解析到隔壁條目;在只有一個 XTI 條目的檔案裡,索引 0 變成索引 1、解析失敗,名稱悄悄降級。只操練重新編譯公式文字的回歸測試永遠看不到它,因為重新編譯根本不碰磁碟索引——這也正是跨表跨活頁簿的定義名稱必須對真實位元組串流測試的原因。解析在兩端都有界:TlxExternSheetSheet.TryResolveXti 對負索引或缺失條目回傳 False,TXLSSupBook.TryGetKind 對超出陣列的 SupBook 索引回傳 False,然後 ClassifyXtislkSelfslkSameSheet 對映到 frcInternalslkExternalWorkbook 對映到 frcExternalWorkbook,把 slkAddInslkDdeslkOleslkDdeOrOle 對映到 frcExternalOther。其餘一切——包括每條越界路徑——都落在 frcUnknownOrMalformed

HotXLS 把 BIFF PtgNameX 語彙的零基 XTI 索引在單一位置轉成內部一基 ExternID,兩端有界解析,並附上消費它的分類對映
零基磁碟索引與一基內部 ExternID 之間的差一轉換只施加一次——語彙進入語法樹時,每個無法解析的索引都落在畸形類別

凍結公式之前先分類

TXLSCompiledFormula.ClassifyReferences 直接掃描保留的 BIFF 語彙串流,而不是反編譯公式再去搜方括號。在公式文字裡獵括號是披著解析器外衣的文字啟發式:它會命中字串字面量、命中結構化參考,還會完全錯過外部定義名稱,因為後者反編譯後根本不帶括號。語彙掃描只看 PtgNameXPtgRef3dPtgArea3dPtgRefErr3dPtgAreaErr3d,在沒有 BIFF 串流倖存時退回語法樹走訪。合併刻意悲觀——固定優先序是 frcUnknownOrMalformed,然後 frcExternalWorkbookfrcExternalOtherfrcInternal——所以一個讀不懂的語彙就能毒害整個公式。對外部定義名稱還要驗證名稱索引:一基、在範圍內、且背後有保留的 ExternName 記錄

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets 是一基的
    begin
      Sheet := Wb.Sheets[i];
      // 只凍結分類為 frcExternalWorkbook 的公式;
      // 內部、增益集、DDE/OLE 與畸形參考都保持為公式
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

OnlyExternal 參數正是分類法值回票價的地方。凍結公式不可逆,所以這個操作必須證明一個參考是外部活頁簿,而不只是懷疑它是。增益集呼叫倖存、DDE 與 OLE 連結倖存、解析器沒能完全理解的東西也倖存,因為面對不確定,安全的結果是什麼都不改。同樣的紀律也適用於重新繫結跨活頁簿複製的公式——在那裡,分類錯誤的參考會重新繫結到錯的活頁簿,而不是大聲失敗

無法解析的記錄原樣寫回

記錄從未被編輯時,HotXLS 保留原始 SupBook 內容並逐位元組重新輸出。解析失敗會設定 slkUnknown 並清除衍生狀態,但擷取的主體留在 FRawData 裡;只要項目不是髒的、也不是自我記錄,儲存路徑就優先採用它而非任何重建。另一種做法——把解析不了的記錄正規化成自我參考,好讓寫入器有個格式良好的東西可輸出——是把一筆您沒看懂的記錄變成一筆確定錯誤的記錄。這條原則與 VBA 專案及其外部參考在載入儲存循環中適用的是同一份契約,而它正是「能往返真實世界檔案的程式庫」與「只能往返測試套件恰好包含的檔案的程式庫」之間的差別。一個歷經十五年的 Excel 版本、一套報表產生器與兩次遷移工具洗禮的活頁簿,會裝著沒有任何在世人員設計過的記錄。照您發現它們的樣子寫回去

SupBook 與 XTI 記錄的型別化分類隨 HotXLS 2.361.2 到 2.361.4 出貨,連同有界 XTI 解析與本文所述更安全的 ConvertFormulasToValues 路徑。如果您維護的 Delphi 或 C++Builder 程式碼要讀取帶增益集呼叫、DDE 或 OLE 連結、外部定義名稱的舊 xls 檔案,HotXLS Delphi 試算表元件原生處理整套分類法,執行工作的機器上不需要安裝 Excel,也不需要 OLE 自動化