技術文章

在 Delphi 讀寫 XLSB 二進位工作簿

HotXLS 從 Delphi 讀寫 Excel Binary Workbook(XLSB)檔案,走的是與 XLSX 同一個 TXLSXWorkbook 門面:Open 從套件內容偵測二進位工作簿,SaveAs 或帶 xlsxExcelBinaryWorkbook 的 SaveAsChecked 則寫出一份。後端涵蓋一個經過驗證的子集,凡它表達不了的東西,在碰到目的地之前就拒絕

最後那半句才是值得慢慢讀的部分。XLSB 看起來像 XLSX 的簡單變體,就封裝而言也確實是。麻煩活在兩種格式編碼方式不同的那些細節裡,也活在一個函式庫把兩者互轉時悄悄做的假設裡。這個後端最有教育意義的 bug 根本不是損壞的記錄,而是一個消失的預設字型,以及一欄在 Excel 裡變成 #### 的日期——值、格式碼與欄寬全都正確

XLSB 檔案是什麼?它與 XLSX 差在哪裡?

XLSB 檔案是一個 OPC 套件,部件結構與 XLSX 工作簿完全一致,只是工作簿、工作表、樣式與共用字串這些部件存的是二進位記錄而非 XML。格式定義在 [MS-XLSB],記錄編碼常被稱為 BIFF12。[Content_Types].xml、relationship 部件與 ZIP 容器照樣有;變的是 xl/workbook.bin 取代 xl/workbook.xml,xl/worksheets/sheet1.bin 取代工作表 XML,以此類推

每條 BIFF12 記錄都從一個精簡的變長標頭開始。記錄型別佔一或兩個位元組,每個位元組七個位元、最高位元當延續旗標,所以記錄型別放得進 14 個位元。承載大小接在後面,用同一套七位元方案,佔一到四個位元組。字串是 32 位元字元數加 UTF-16 code unit。跟 BIFF8 相比,這個版面乾淨得多:沒有 8224 位元組的 CONTINUE 切分,記錄中間也沒有壓縮對寬字串的旗標。跟 XLSX 相比,它完全跳過 XML 詞法分析,這就是大型 XLSB 檔往往更小、Excel 開啟更快的原因

有一個封裝細節常絆倒偵測程式碼。Excel 用內容類型 application/vnd.ms-excel.sheet.binary.macroEnabled.main 註冊 XLSB 工作簿部件,就算工作簿裡一個巨集也沒有。HotXLS 先查 [Content_Types].xml 裡的這個註冊,只有內容類型缺席時才退回看 .bin 部件副檔名,而且兩條路都還得通過嚴格的記錄驗證器。部件名稱、relationship 與內容類型的一般規則,與Excel 為什麼修復合法 XLSX 及其背後的 OPC 套件規則一文講的是同一套

什麼時候該選 XLSB 而不是 XLSX?

下游系統要求 XLSB、或您要跟在乎開啟速度的 Excel 使用者交換大量數值的工作簿時,就選 XLSB;否則 XLSX 仍是更穩的預設。XLSX 是 ISO 標準(ISO/IEC 29500),工具支援廣,用 ZIP 工具加文字編輯器就能檢視,每一家試算表產品都讀得動。XLSB 是 Microsoft 的格式,有公開規格,但生態系窄

實務上需求來自兩個方向。有些報表管線與封存多年前就標準化在 .xlsb 上,其他格式一概拒收。另一些團隊產出幾十萬個數值儲存格的工作簿,發現使用者打開二進位版明顯更快。這兩個都是正當理由。「聽起來比較有效率」不是,因為 HotXLS 的二進位後端會把完整套件暫存在記憶體裡,不是記憶體有界伺服器匯出該用的工具。那種場景該走的是串流 XLSX 寫出器

用 TXLSXWorkbook 開啟與儲存 XLSB

TXLSXWorkbook.Open 不需要格式提示:同一個呼叫能載入 XLSX、XLSM 或 XLSB,二進位載入成功後 TXLSXWorkbook.SourceFormat 會回報 xlsxExcelBinaryWorkbook。寫出則必須明講。不帶格式的 SaveAs(FileName) 永遠產出 Open XML 工作簿,就算檔名以 .xlsb 結尾也一樣,所以二進位輸出一定要指名格式

uses
  SysUtils, lxHandleX;

procedure ConvertToXlsb(const Source, Target: string);
var
  Book: TXLSXWorkbook;
begin
  Book := TXLSXWorkbook.Create;
  try
    // Open 從套件本身偵測 XLSX、XLSM 或 XLSB
    if Book.Open(Source) <> 1 then
      raise Exception.Create('Input is unsupported or invalid');

    // 光靠副檔名絕不會選中二進位輸出
    if Book.SaveAsChecked(Target, xlsxExcelBinaryWorkbook) <> 1 then
      raise Exception.Create('XLSB output is unsupported or failed');
  finally
    Book.Free;
  end;
end;

串流輸出走 SaveAs(Stream, xlsxExcelBinaryWorkbook) 或 SaveAsChecked 的串流多載。內部兩者都路由到私有的 SaveAsXlsbStream 與 SaveAsXlsbFile 方法;您永遠不會直接呼叫它們。檔案輸出會先寫到同目錄的暫存檔,最後一次替換提交。串流目的地必須可讀且可 seek,因為寫出器會快照它的位元組、大小與位置,最後複製失敗時恢復原狀。二進位存檔之後 TXLSXWorkbook.LastSaveUsedStreaming 回報 False,這很準確:在第一個位元組抵達目的地之前,整套套件就已經完整存在記憶體裡

為什麼是經過驗證的子集,而不是完全保真?

HotXLS 不為 XLSB 維護第二套物件模型。開啟時,XlsbToOpenXml 把二進位套件轉成記憶體內的 Open XML 套件,交給普通的 XML 解析器載入;存檔時,XML 寫出器建出一個套件,OpenXmlToXlsb 把它編碼成二進位記錄。因此每個功能都必須挺過兩次翻譯,橋接器無法精確翻譯的功能會被拒絕,而不是被近似

以 HotXLS 2.384.113 為準,支援集合對大多數資料工作簿來說夠廣:帶型別的值、共用與內嵌字串、常見字型、填滿、框線與數字格式、列高與欄寬、帶快取結果的普通儲存格公式、已定義名稱、合併儲存格、超連結、凍結與分割窗格、帶簡單自訂準則的自動篩選範圍,以及普通的工作表表格。集合之外的是共用與陣列公式、前瞻函式與使用者自訂函式、跨工作簿參照、圖表與繪圖、樞紐分析表與交叉分析篩選器、VBA、條件格式、資料驗證,以及手動計算或反覆運算這類非預設的計算設定

拒絕是強制的,不是建議性的。TXLSXWorkbook.GetConversionReport(xlsConversionExcelBinaryWorkbook) 會在您存檔前列出不支援的功能,這些條目就算設了 AAllowLossy = True 或用不檢查的 SaveAs,照樣讓存檔失敗。這是刻意的。從二進位副本裡悄悄丟掉一個樞紐分析表,那種損失要等工作簿擺到客戶面前才會有人發現

uses
  Classes, lxHandleX, lxDiagnostics;

function CanSaveAsXlsb(Book: TXLSXWorkbook; Log: TStrings): Boolean;
var
  Report: TXLSConversionReport;
  I: Integer;
begin
  Report := Book.GetConversionReport(xlsConversionExcelBinaryWorkbook);
  try
    Result := True;
    for I := 0 to Report.Count - 1 do
      if Report[I].Fidelity = xlsConversionUnsupported then
      begin
        Result := False;
        Log.Add(Report[I].SheetName + ' ' + Report[I].Location + ': ' +
          Report[I].Feature + ' - ' + Report[I].Message);
      end;
  finally
    Report.Free;   // 報告歸呼叫者所有
  end;
end;

報告覆蓋的是活著的模型。最終的套件驗證仍然是模型看不到的東西的最後一道閘——例如來源檔裡損壞的記錄或不認得的屬性——所以「報告乾淨、存檔失敗」是可能發生的,您該依據的是存檔結果

預設字型與 0.00 格式為什麼消失了?

預設字型與內建數字格式會消失,是因為 XML 樣式讀取器做了兩個假設——對 HotXLS 自己寫出的檔案成立,對 Excel 寫出的工作簿不成立。橋接器把兩種資源都保存得完好無缺;損失發生在上一層,公開的 Open 路徑裡。這個落差是本後端最主要的一課:格式橋接正確,不代表公開往返就忠實

第一個假設關於 fonts[0]。HotXLS 存 XLSX 時,會把一個隱含的預設字型寫進零號槽位,它的 XML 讀取器會跳過那個槽位,讓面向使用者的 Fonts 集合從索引零開始。Excel 寫的 XLSB 在那個槽位帶著自己明確的預設,跳過它就把那個選擇扔了。第二個假設關於數字格式。讀取器只把自訂的 numFmtId(164 以上)映射進 NumberFormats;內建 id 一律留成零。一個帶內建格式 2(也就是 0.00)的帶樣式空白儲存格,回來變成了 General

修復完全落在二進位匯入路徑裡。完整套件通過 preflight 之後,XLSB 開啟會只為同步 XML 解析設一個私有的樣式模式。該模式下,讀取器載入包括零號槽位在內的每一個字型、把每個 fontId 平移一位,把每個支援的內建 id 轉成模型裡明確的格式碼,日期偵測仍用原始的 numFmtId,所以內建日期格式保得住日期語意。finally 區塊一定會清掉這個模式,之後普通的 XLSX 或 ODS Open 也會清掉保留的預設字型狀態

fonts[0] 怎麼影響 Excel 的欄寬?

Excel 計算欄寬的依據是 fonts[0] 裡的工作簿預設字型,不是套在該欄儲存格上的字型。欄 width 以預設字型最大數字寬度的字元單位存放,所以換掉 fonts[0] 會改變每一欄的像素寬度——包括儲存格全用不同字型的欄

這造成了整個調查裡最令人困惑的症狀。重新存出的工作簿,儲存格值相同、格式碼相同、width 屬性相同,儲存格字型與 Normal 樣式都指向保留下來的字型,Excel 卻仍然把日期欄畫成 ####。寫出的 fonts[0] 退回了 Calibri 11,最大數字寬度縮水,日期塞不下了。HotXLS 現在會保留來源的預設字型、把它寫回 fonts[0],只對從來沒有二進位來源的工作簿才退回 Calibri 11。完整的欄寬算術見Delphi 的 Excel 欄寬與最大數字寬度(MDW)

依賴地區設定的內建格式會被拒絕,而不是靠猜

沒有明確格式碼的內建數字格式 id,只有在其意義固定時才被接受;依賴地區設定的 id 在套件 preflight 時就會被拒。HotXLS 把 id 0 到 22、37 到 40 與 45 到 49 映射到已知碼,例如 0.00、0%、mm:ss 與 @。164 以下剩下的 id 屬於渲染效果取決於 Excel 安裝之區域設定的格式,匯入會以訊息「A locale-dependent number format requires an explicit format code」拒收

替那些 id 硬編一個格式碼,比失敗更糟。猜出來的東亞日期樣式看起來有模有樣、往返不出錯、卻對一半的使用者顯示錯的東西。時機也有講究:這項檢查在整包 preflight 裡跑,早於清空既有工作簿,所以被拒的檔案不會動到先前載入的模型、檔名與來源格式。Excel 日期序號與 numFmt 一文講的是內建日期 id 與 1900、1904 日期系統怎麼互動

procedure LoadBinaryWorkbook(Book: TXLSXWorkbook; const FileName: string);
var
  I: Integer;
begin
  // 二進位輸入需要立即完整載入;選擇性模式會預先拒絕
  Book.MetadataOnly := False;
  Book.SelectedSheets.Clear;

  if Book.Open(FileName) <> 1 then
  begin
    for I := 0 to Book.Diagnostics.Count - 1 do
      Writeln(Book.Diagnostics[I].Message);
    Exit;   // 先前載入的工作簿仍完好無缺
  end;

  // 公式輸出需要快取,除非存檔時會重算
  Book.RecalcBeforeSave := xrmAlways;
end;

生產程式碼裡用 XLSB 的實用規則

把二進位後端的拒絕當成資料、而不是當成要壓下去的錯誤,它就是可以放心依賴的。以下幾條規則來自上面的行為

  • 每次二進位存檔都指名格式;光有 .xlsb 副檔名仍會產出 XLSX
  • 存使用者編輯過的工作簿之前,先跑 GetConversionReport(xlsConversionExcelBinaryWorkbook),把不支援的功能攤給使用者看
  • 二進位輸入請讓 MetadataOnly 關著、SelectedSheets 留空,因為延遲載入得保留一份轉換過的封存
  • 公式要有快取結果、或存檔前重算,因為缺快取在 xrmManual 下會被拒,在乾淨的 xrmIfDirty 下也會
  • 為記憶體編好預算:完整轉換套件加二進位輸出都要算進去;記憶體必須有界時用串流 XLSX

測試時,請透過公開 API 與 Excel 比對,而不是在橋接層比。預設字型與 0.00 的迴歸都通過了橋接測試,卻都在一份 Excel 寫的工作簿經 Open 與 SaveAsChecked 開啟再存之後立刻現形

FAQ

HotXLS 會自動偵測 XLSB 檔案嗎?

會。TXLSXWorkbook.Open 檢查工作簿部件註冊的內容類型並驗證二進位記錄,同一個呼叫就能載入 XLSX 與 XLSB,SourceFormat 會回報讀到的是哪一種

為什麼 SaveAs 用 .xlsb 檔名還是寫出 XLSX?

HotXLS 絕不從副檔名推斷輸出格式。要把工作簿寫成二進位,請把 xlsxExcelBinaryWorkbook 傳給 SaveAs 或 SaveAsChecked

工作簿用到 XLSB 子集之外的功能時會怎樣?

存檔會在目的地被動到之前失敗,就算允許有損存檔也一樣。GetConversionReport 事先列出擋路的功能,Diagnostics 事後解釋失敗原因

XLSB 支援、轉換報告與其餘工作簿 API,都在 HotXLS——Delphi 與 C++Builder 原生試算表元件裡