技術文章

用 Delphi 與 HotXLS 開啟與儲存 ODS 檔案

一個多年來發出 .xlsx 的 Delphi 報表後端,接到了一項新需求:某個公部門客戶的採購規則強制規定 OpenDocument 試算表輸出,而那個帳號上的分析師把他們的編輯以從 LibreOffice 儲存的 .ods 檔案傳回。所以現在同一份程式碼既要寫 ODS,也要讀它。HotXLS,losLab 供 Delphi 與 C++Builder 使用的原生 Object Pascal 試算表程式庫,在完全不安裝 Excel 或 LibreOffice 的情況下處理兩個方向。兩個方向相近,但並不完全相同。自 HotXLS 2.88.229 起,匯入路徑會把公式以活公式形式帶回,並排在其快取結果旁,而目前的版本也會讀取樣式、數字格式、合併區域、註解、圖片、圖表與條件格式。仍然無法倖存的是完整的 OpenFormula 語意,以及少數幾個封包層級的細節,因此假設兩邊是完美鏡像的團隊,還是會在客戶的修訂與下一份報表之間的某處碰到邊緣案例,而且通常沒有錯誤可指

ODS 支援位於 XLSX 外觀層,而非 XLS 那一個

HotXLS 在單一套件裡附帶兩個獨立的類別階層:lxHandle 單元中的 TXLSWorkbook 供二進位 BIFF8 .xls 檔案使用,lxHandleX 單元中的 TXLSXWorkbook 供 OOXML .xlsx 封包使用。每一個 OpenDocument 進入點——OpenODS、SaveAsODS、GetODSSheetNames——都掛在 TXLSXWorkbook 上。這個位置不是任意的。一個如 OASIS ODF 1.3 所指定的 ODS 封包,是一個帶有 mimetype 成員、一份資訊清單與一個 content.xml 主體的 zip 封存,這使它成為 OOXML zip 的結構表親;BIFF8 則是一條 1990 年代的二進位記錄串流,兩者毫無共通之處

這個位置有一個實用的鋒利邊:一份舊式 .xls 活頁簿無法一次呼叫就變成 .ods。你得先把 BIFF 內容透過 lxXlsxExport 單元的 SaveXLSWorkbookAsXLSX 橋接進 XLSX 模型,透過 TXLSXWorkbook 重新開啟結果,再從那裡匯出。這座橋不是無損的,而在你基於它建置之前,值得先了解缺口。它複製值、公式、數字格式、字型、填色、框線、合併區域、欄寬與列高。它丟棄註解、圖表與條件格式。一份格式繁重的 .xls 來源,抵達 ODS 時會比它離開時看起來更樸素,而那是橋接的屬性,不是 ODS 寫入器的

匯入端的偵測是自動的。普通的 Open 方法透過 mimetype 成員辨識 ODS 封包,當該成員不存在時,會退而檢查頂層的 content.xml,所以一條通用的「無論使用者上傳什麼都開啟」的程式碼路徑,不需要自己嗅探副檔名。開啟之後,SourceFormat 屬性回報是哪個分支被觸發

HotXLS 在 Delphi 的類別布局圖解:每個 ODS 進入點都位於 TXLSXWorkbook 上,SaveXLSWorkbookAsXLSX 橋接把 BIFF8 .xls 內容帶過來
每個 OpenDocument 進入點都掛在 TXLSXWorkbook 上;舊式 .xls 想到 ODS,只能走有損的 BIFF 對 XLSX 橋

以 TODSExportOptions 匯出為 ODS

匯出呼叫本身是一行;圍繞它的選項物件帶著審查者之後會問起的決定:

var
  Book: TXLSXWorkbook;
  Opts: TODSExportOptions;
begin
  Book := TXLSXWorkbook.Create;
  try
    Book.Open('quarterly-report.xlsx');
    Opts := TODSExportOptions.Create;        // 由呼叫者擁有並釋放
    try
      Opts.Generator := 'ReportService 4.2'; // meta:generator 覆寫
      Opts.IncludeCharts := True;
      Opts.IncludeImages := True;
      Book.SaveAsODS('quarterly-report.ods', Opts);
    finally
      Opts.Free;
    end;
  finally
    Book.Free;
  end;
end;

選項物件由呼叫者擁有。HotXLS 不會釋放它,這也是為什麼內層的 try..finally 在那裡、而且不是選用的。兩個改變輸出而非只是為它貼標籤的屬性,值得更仔細地看。把 IncludeCharts := False 不只是隱藏圖表:它會把圖表子文件與它們的資訊清單項目從封包中剝除,這正是一個會被圖表絆倒的資料管線消費者所想要的。Generator 覆寫 ODF 的 meta:generator 字串,否則它讀作 HotXLS/<version>;當下游工具對檔案產生者做指紋辨識以路由支援時,請覆寫它。如果這些都不適用,就完全略過選項物件。呼叫 SaveAs(FileName, xlsxOpenDocumentSpreadsheet) 與使用預設值的 SaveAsODS 相同,而兩者的串流多載都讓你能把封包直接寫進 HTTP 回應而無暫存檔

匯入路徑讀取什麼——又仍然略過什麼

在向任何人承諾往返保真之前,請仔細讀這一段。HotXLS 中的 ODS 匯入起初是一條只保留純量值與快取公式結果的輕量級路徑,如今早已遠超過那個範圍。目前的版本會連同 styles.xml 與 settings.xml 一起讀取 content.xml:純量值與快取的公式結果、展開進格線的重複列與欄、合併區域、豐富文字、超連結、註解、內嵌圖片與圖表、繼承與預設儲存格樣式、數字與日期格式、列高與欄寬、命名範圍、凍結窗格、AutoFilter 範圍、資料驗證,以及條件格式。OpenODS 仍然不轉換的是完整的 OpenFormula 語意、ODS 保護金鑰、註解幾何位置、進階註解格式,以及試算表內容之外的封包功能

公式是最可能咬人的部分。一個 ODF 儲存格並排儲存兩樣東西:公式運算式,以 ODF 1.3 Part 4 定義的 OpenFormula 方言寫成;以及產生它的應用程式為它算出的最後一個值。早期版本只讀取快取值。自 HotXLS 2.88.229 起,OpenODS 也會匯入運算式本身:以 of:= 或 oooc:= 前綴寫入的儲存格,會保留其快取值並得到正規化後的 Excel 公式,諸如 [.A1:.B1] 這類方括參照,以及分號引數分隔符,都會轉換成 Excel 語法。自 2.384.32 起,匯入也會轉換列內陣列({1;2|3;4} 變成 {1,2;3,4})與 ~ 參照聯集運算子;自 2.384.55 起,這類聯集讀回時不再多出一對括號。把 OpenFormula 翻譯成 Excel 語法,仍然是一個方言轉換問題,在函數詞彙、參照語法與錯誤模型上有真實的邊緣案例——這正是快取值隨行的原因:你匯入的數字,正是傳送者最後看到的數字

需要圍繞它設計的失敗模式也因此收窄了。在 2.88.229 之前,一份其合計在 LibreOffice 上次儲存時是正確的試算表,匯入時會帶有正確但已成常數的數字:編輯一個輸入儲存格、重新計算,什麼也不動。在目前的版本上,公式會以活的形式回來,剩下的風險是轉譯器無法建模的結構——那可能讓某條公式的重算結果與產生它的應用程式不同,而快取值仍顯示傳送者的結果。對管線所依賴的公式,請把重算結果與匯入的快取值比對,若有不一致,就透過 Cell.Formula 從你自己的業務規則重新建立——在 XLSX 外觀層上,它接受運算式而不帶前導等號

圍繞剩餘的往返缺口來設計

匯出從完整的記憶體內活頁簿模型繪製:值、公式、樣式,以及——如果你要求——圖表與圖片。公式匯出逐版收緊。自 2.384.55 起,列內陣列、參照聯集與絕對參照在 OpenFormula 中保持原義;自 2.384.56 起,HotXLS 為其公式前綴宣告命名空間,LibreOffice 因此會真正計算公式,而不是在每個公式儲存格顯示 #VALUE!;自 2.384.65 起,整欄與整列參照以 OpenFormula 形式 [.A:.A] 寫出,Excel 也就不再把它們當成 #NAME? 開啟。條件格式隨後跟上:自 2.384.66 起,以公式為基礎的規則連同其基準儲存格一起寫成 formula-is(...),LibreOffice 能夠辨識;自 2.384.69 起,數值與公式規則也會寫成標準的 style:map 條件——Excel 唯一讀得懂的形式——而匯入端兩種形式都讀。因此,在目前的版本上,一趟完整的 .xlsx 到 .ods 再回 .xlsx 的循環,能保留值、公式、樣式與條件格式;仍可能失去的,落在上一節列出的那些缺口裡

HotXLS 從 Delphi 出發的 ODS 往返圖解:從記憶體內活頁簿模型匯出,早期版本的匯入僅取值,在 HotXLS 2.88.229 之前公式以常數形式留下
早期版本只匯入值與快取結果,完整的 .xlsx 到 .ods 再回 .xlsx 循環因此丟掉樣式與活公式;目前的版本兩者都讀得回來
Book := TXLSXWorkbook.Create;
try
  Book.Open('vendor-revision.ods');          // 格式自動偵測
  if Book.SourceFormat = xlsxOpenDocumentSpreadsheet then
  begin
    // 自 HotXLS 2.88.229 起,公式會以活的形式帶回,排在快取結果旁;
    // 下游管線無法信賴其轉譯結果的任何公式,
    // 請在儲存前重新建立。
    Book.Sheets[0].Cells[2, 5].Formula := 'SUM(B2:D2)';
    Book.SaveAs('vendor-revision.xlsx');
  end;
finally
  Book.Free;
end;

由此產生的架構模式是:把內送 .ods 檔案當作資料來源,而非就地編輯的文件。把規範活頁簿保留在 .xlsx,從客戶修訂中讀出值,並依需求從規範副本發出新鮮的 ODS。驗證兩邊都該做——在 LibreOffice Calc(參考 ODF 消費者)與 Excel(它讀 ODS 已多年,但在圖表與樣式支援的邊緣與 LibreOffice 意見不一致)中開啟匯出的檔案。工作表數量、少數幾個關鍵儲存格與圖表是否存在,對每個匯出設定檔構成充分的煙霧檢查

在投入匯入之前先分診 ODS 檔案

當一個端點接受上傳時,列工作表名稱遠比完整解析便宜,而且能提早抓到結構上的意外:

HotXLS 在 Delphi 的上傳分流關卡圖解:GetODSSheetNames 在完整匯入執行前,拒絕無法讀取的 ODS 套件與缺少的工作表
GetODSSheetNames 探測的成本遠低於完整剖析,趁錯誤還叫得出檔名時就抓住工作表改名的失敗
Names := TStringList.Create;
Book := TXLSXWorkbook.Create;
try
  if Book.GetODSSheetNames('incoming.ods', Names) <= 0 then
    raise Exception.Create('not a readable ODS package');
  if Names.IndexOf('Data') < 0 then
    raise Exception.Create('revision is missing the Data sheet');
finally
  Book.Free;
  Names.Free;
end;

回傳慣例會絆倒人:HotXLS 的呼叫一般在成功時回傳一個正計數或 1,在失敗時回傳 -1,並在失敗時清空清單,所以請測試 <= 0,而不是拿來與某個特定的正值比較。GetODSSheetNames 既不重設也不填入活頁簿執行個體,所以單一探測物件就能審查整個目錄的內送檔案。像這樣的結構檢查,能在閘口抓到最常見的現實世界失敗——某個分析師在把修訂傳回前重新命名或刪除了某個工作表——在那裡錯誤訊息仍能點名檔案與缺失的工作表,而不是在三層深處以一個 nil 參考浮現

如果你打算圍繞這個建置更廣的轉換管線,活頁簿稽核與轉換工作台模式示範了如何在選擇目標格式之前先盤點檔案特徵,而大型活頁簿效能指南則讓批次匯出保持在合理的記憶體範圍內

HotXLS 是一個附帶完整原始碼的原生 Delphi 與 C++Builder 試算表程式庫;完整的功能清單與授權詳情位於HotXLS Delphi Component 產品頁