技術文章

HotXLS:Delphi 活頁簿審核與格式轉換

一項大量試算表正規化工作,是三個問題穿著同一件外衣。你有一個混雜格式的檔案庫:BIFF 時代的 .xls、現代的 .xlsx、一些來自某次 LibreOffice 實驗的 .ods,還有一小撮沒人打得開的檔案,因為密碼跟著前任員工一起離開了。目標是把全部轉成 XLSX 與 CSV。多數人寫的那版工作,是一個開啟每個檔案並以新副檔名儲存的迴圈,它一直運作良好,直到有人問起哪些檔案弄丟了圖表、掉了巨集、或根本打不開。這個迴圈答不出來,因為單靠轉換不保留任何紀錄。工作台會:它先盤點、再轉換、最後驗證,而這三個階段必須彼此共享資訊,整套才值得信賴

在 Delphi 或 C++Builder 裡組裝這樣一個工作台,代表要把 HotXLS 的四項能力接起來,而這些能力沒有任何一個需要在管線裡安裝 Excel。這裡有兩個原生引擎,一個用於 .xls 的 BIFF8 外觀,和一個用於 .xlsx.ods 的 OOXML 外觀。這裡有不花成本的探測呼叫,能在不解析整個檔案的情況下讀取中繼資料。這裡有每張工作表的審核計數器,告訴你活頁簿實際裝了什麼。這裡還有一個轉換矩陣,每條路線都附有記載的保真度設定檔。工作就在於知道這些東西各自的尖角在哪裡,因為每一個都有,而那些尖角恰恰就是把一個乾淨的隔夜批次變成週一早晨事故的東西

HotXLS 在 Delphi 的稽核優先轉換工作台管線圖:先清點 xls、xlsx 與 ods 混合封存,按路線轉換,再對照清點時記錄的轉換前數字驗證
工作台分三階段轉換,盤點期間記下的稽核計數成了驗證對照的先前數字

載入前先探測:工作表名稱與加密偵測

開啟一個 200 MB 的活頁簿,結果才發現它是加密的,每個檔案就浪費幾分鐘,乘上一個大型檔案庫就浪費了好幾天。兩個外觀都公開了 GetSheetNames,能在不填入活頁簿的情況下讀取工作表中繼資料。BIFF 實作只掃描資料流前段的 BoundSheet 紀錄;OOXML 實作只讀取 zip 裡的 workbook.xml。在它旁邊,CanReadEncrypted 能在不嘗試解密的情況下偵測出加密容器:

var
  Probe: TXLSXWorkbook;
  Names: TStringList;
begin
  Names := TStringList.Create;
  Probe := TXLSXWorkbook.Create;
  try
    if Probe.CanReadEncrypted(FileName) then
    begin
      Writeln(FileName + ': encrypted container - route to manual handling');
      Exit;
    end;
    if Probe.GetSheetNames(FileName, Names) <= 0 then
      Writeln(FileName + ': unreadable - quarantine')
    else
      Writeln(Format('%s: %d sheet(s), first "%s"',
        [FileName, Names.Count, Names[0]]));
  finally
    Probe.Free;
    Names.Free;
  end;
end;

兩個運作細節讓這個迴圈不花成本。GetSheetNames 不會重置或填入活頁簿實例,所以單一探測物件就能分類數千個檔案而不必重新建立。而同一個呼叫的 XLS 外觀版本也懂 .xlsx 封包,這讓它在副檔名不可信時成為一個方便的單一探測工具——而在那麼舊的檔案庫裡,副檔名鮮少可信。載入前的分診值得一篇專文討論;輕量檢視的機制寫在我們的工作表清單與輕量活頁簿檢視文章

HotXLS 在 Delphi 的活頁簿批次分流流程圖:CanReadEncrypted 把加密容器導向人工處理,GetSheetNames 隔離無法讀取的檔案,通過的檔案進入決定轉換路線的稽核階段
以 CanReadEncrypted 與 GetSheetNames 探測,每個檔案在載入前就完成分類;加密與讀不開的活頁簿從此不進轉換迴圈

計算活頁簿真正裝了什麼

一旦某個檔案通過分診,審核階段就決定它的轉換路線。XLSX 外觀為每一個會影響保真度決策的功能家族公開一個計數器:合併儲存格、圖表、圖片、條件式格式設定、資料驗證、表格、超連結與註解,再加上活頁簿層級的巨集、保護與來源格式旗標。一個檔案的轉換路線,幾乎完全取決於這些當中哪些回傳非零

var
  Book: TXLSXWorkbook;
  Sheet: TXLSXWorksheet;
  I: Integer;
begin
  Book := TXLSXWorkbook.Create;
  try
    if Book.Open(FileName) <> 1 then Exit;
    for I := 0 to Book.Sheets.Count - 1 do
    begin
      Sheet := Book.Sheets[I];
      Writeln(Format('%s: cells=%d merges=%d charts=%d cf=%d dv=%d protected=%s',
        [Sheet.Name, Sheet.Cells.Count, Sheet.MergedCells.Count,
         Sheet.Charts.Count, Sheet.ConditionalFormats.Count,
         Sheet.DataValidations.Count, BoolToStr(Sheet.IsProtected, True)]));
    end;
    if Book.HasVbaProject then
      Writeln('  contains VBA project - macro policy applies');
    if Book.ExternalLinks.Count > 0 then
      Writeln(Format('  %d external link(s)', [Book.ExternalLinks.Count]));
  finally
    Book.Free;
  end;
end;

讀取 Cells.Count 時要記得一個但書。儲存格存放區是稀疏的,所以這個數字計算的是已實例化的儲存格,而非使用範圍的矩形面積。一張在 A1 放一個值、在 ZZ9999 放另一個值的工作表,會回報兩個儲存格,而不是夾在兩者之間的上百萬個。BIFF 那一側的同等掃描使用 UsedRange 邊界搭配 ForEachCell,而它帶著那個第一次幾乎會絆倒所有人的差一錯誤:UsedRange.FirstRow 及其同類是 0 起算的,而 Cells.Item[Row, Col] 是 1 起算的。一個忘了在每個邊界加一的遍歷,會審核到錯誤的矩形,而且從不吭聲

有兩個控制桿能降低只做審核的階段在大型舊檔案上的成本。在開啟 .xls 之前把 _DisableGraphics 設為 true,會完全跳過 OfficeArt 繪圖層的解析,這對塞滿形狀的活頁簿來說能省下實質時間。不過它嚴格來說是唯讀的最佳化:從以那種方式開啟的實例來儲存,會丟掉它從沒解析過的繪圖,因此這個旗標只屬於永遠不會把檔案寫回去的路徑。當審核需要逐格內容而非計數時,ForEachCell 回呼會直接走過已填入的儲存格,並避開索引式儲存格屬性在每次讀取時付出的每次存取 Variant 額外開銷,而那筆開銷在數百萬個儲存格之間會快速累積

及早正規化不一致的回傳碼

HotXLS 的 I/O 呼叫透過整數結果回報錯誤,而非例外,而且這些慣例在整個 API 裡並不一致。多數開啟與儲存呼叫在成功時回傳 1、失敗時回傳 -1。GetSheetNames 回傳工作表數量,或是在清單被清空時回傳 -1。XLSX 的 SaveAsHTML 再次打破模式,成功時回傳 0,工作表索引超出範圍時回傳 -1。一個到處測試 = 1 的工作台,會悄然誤判那些以其他方式表示成功的呼叫,而一個測試 <> -1 的,則會吞掉那些以不同代碼失敗的呼叫

能在與整個 API 接觸後存活下來的規則,比表面看起來更窄:對回傳計數的呼叫把 <= 0 當成失敗,對你實際用到的每個儲存常式檢查其記載的成功值,並把兩者都藏在一個小的結果檢查函式後面,好讓慣例只存在於一個地方。批次管線因未檢查回傳碼緩慢堆積而失敗的頻率,遠高於任何稀奇的解析器臭蟲,而把這件事搞錯的代價,是到了四萬個檔案之後,才沒人記得哪些轉換真的成功

轉換矩陣,以及每條路在哪裡丟資料

兩個外觀把轉換工作分攤。TXLSXWorkbook 開啟 XLSX、ODS 與 CSV,並儲存 XLSX、ODS、CSV、HTML、RTF 與 AES 加密的 XLSX。TXLSWorkbook 開啟並儲存 BIFF,並匯出 HTML、RTF 與 CSV。有用的是,每條路徑都附有一份記載的保真度設定檔,而非一個含糊的正確性承諾,所以你能事先決定哪些路線對哪些檔案是安全的

CSV 匯出寫入 UTF-8 並帶 BOM、CRLF 列尾與 RFC 4180 引號。它不做的事是評估公式:一個持有 =SUM(...) 的儲存格會以字面的公式文字匯出,所以一張公式的活頁簿會變成一張字串的活頁簿,除非你先把數值算出來。HTML 匯出產生單一表格,以 colspan 與 rowspan 取代合併儲存格,並內嵌基本樣式。RTF 匯出有一個更尖銳的限制:它無法讓合併儲存格跨越欄,所以合併的接續儲存格會變成空的。ODS 匯入是刻意做成輕量的,這是函式庫自己文件說的。純量值與快取的公式結果會過來;樣式、活生生的 ODF 公式運算式與繪圖則不會。這一點在檔案庫裡出現受 OASIS ODF 1.3 規範的真正 OpenDocument 檔案的那一刻就很重要,因為任何接近視覺忠實的轉換,都需要比這條匯入路徑被設計來承載的更多,而審核階段就是在批次悄然把它們壓平之前,告訴你這些檔案存在的那個機制

SaveXLSWorkbookAsXLSX 是資料橋,不是版面橋

BIFF 外觀無法直接寫入 OOXML,所以從 .xls.xlsx 的跨越要跑過 lxXlsxExport 單元裡的 SaveXLSWorkbookAsXLSX 函式。那座橋的保真度值得直白地說清楚,因為它的名字暗示的比它做的更多。它複製數值、公式、數字格式、填滿色彩、核心字型屬性、欄寬,以及格線這類檢視設定。它不複製邊框、合併範圍、註解、圖表或條件式格式設定。對資料等級的正規化來說——下游系統會解析結果、沒人會看格式——那恰好足夠,而且沒有任何人需要的東西會遺失。對一份要給人讀的格式化董事會報表來說,那不夠,而這恰恰是審核計數器掙得自己位置的地方:一個被審核標記為帶有圖表與條件式格式設定的檔案,應該路由到人工佇列,而不是跑過一座會把兩者都不吭一聲丟掉的橋

HotXLS SaveXLSWorkbookAsXLSX 在 Delphi 的橋接保真圖:值、公式、數字格式、填色、核心字型屬性、欄寬與檢視設定可從 BIFF xls 跨到 XLSX;框線、合併範圍、註解、圖表與條件格式則被捨棄
SaveXLSWorkbookAsXLSX 把剖析器需要的資料送過 BIFF 對 OOXML 的橋;稽核計數負責標出圖表與合併會被丟掉的檔案
var
  Legacy: IXLSWorkbook;        // 介面參照:不要釋放
  Modern: TXLSXWorkbook;
begin
  if SameText(ExtractFileExt(FileName), '.xls') then
  begin
    Legacy := TXLSWorkbook.Create;
    if Legacy.Open(FileName) <= 0 then Exit;
    if SaveXLSWorkbookAsXLSX(Legacy,
         ChangeFileExt(FileName, '.xlsx')) <= 0 then
      Writeln('bridge failed: ' + FileName);
  end
  else
  begin
    Modern := TXLSXWorkbook.Create;
    try
      Modern.StreamingWrite := True;     // 將工作表 XML 串流寫入 zip
      if Modern.Open(FileName) = 1 then
        Modern.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
    finally
      Modern.Free;
    end;
  end;
end;

上面的迴圈也展示了 OOXML 那一側的吞吐量控制桿。把 StreamingWrite 設為 true,會把工作表 XML 直接串流進輸出封包,而不是在記憶體裡暫存成一根巨大的字串,這在檔案一旦達到數十萬列時,就是舒適執行與記憶體不足崩潰之間的差別。該模式的容量與記憶體行為在我們的伺服器批次工作串流寫入文章裡有專文討論。對一個想用滿每個核心的批次來說,還有一個屬性很重要:兩個外觀都不是執行緒安全的,但也都不共享全域狀態,因此平行轉換受支援的模式是每個工作者執行緒一個活頁簿實例,彼此之間不加鎖

那些密碼檔案,以及拿它們怎麼辦

檔案庫裡鎖住的檔案,依格式分得很乾淨,而這個分裂決定了它們去哪裡。舊式 .xls 加密,無論是 RC4、RC4 over CryptoAPI、還是舊的 XOR 混淆,都可讀:把密碼傳給 Open,檔案就像其他檔案一樣轉換。加密的 .xlsx 封包是另一回事。HotXLS 用 CanReadEncrypted 偵測得到它們,卻無法解密,所以唯一誠實的動作,是把它們路由到一個由人工在 Excel 裡開啟並重新儲存、然後才重新加入管線的佇列。這個不對稱值得從一開始就為它設計,因為加密的 XLSX 檔案,恰恰最可能是某個真正在乎的紀錄

用驗證把迴圈關起來

第三個階段是最常被跳過的,而跳過它,正是把大量轉換變成一筆責任的原因。HotXLS 裡沒有任何儲存路徑會評估公式。Excel 在開啟檔案時會重新計算,所以 XLSX 對 XLSX 的轉換保持正確,但 CSV 目標會逐字收到公式文字,除非管線先對儲存格執行 Calculate 並把結果寫回。事先知道這一點,就是一個裝滿數字的 CSV,與一個裝滿 =SUM(...) 字串、直到下游匯入被噎到才有人察覺的 CSV,兩者之間的差別

驗證本身就便宜到沒有藉口把它省掉。用同一個函式庫重新開啟每個轉換過的檔案,重新執行審核計數器,並拿它們與盤點階段已經記錄的轉換前數字比較。一個掉下來的工作表數量、一個在來源有三個卻歸零的圖表數量、一個懸崖式下跌的儲存格數量:每一個都是用第二次開啟的成本逮到的悄然損失。在那之上,再用 Excel 或 LibreOffice 以肉眼抽查一個樣本,這個組合就能在出貨前抓到絕大多數的轉換損壞。這正是盤點階段餵給驗證階段的全部理由。沒有轉換前的數字,轉換後的數字什麼也證明不了

一個以審核為先的工作台,把一次有風險的大量轉換變成一個可量測的流程,並為無法乾淨通過的檔案開一條隔離車道。這裡展示的所有探測、計數與轉換呼叫,都是HotXLS Delphi Component 的一部分,它在處理程序內原生執行,不需 Excel 自動化