技術記事

HotXLS Delphi Component: Delphi での ODS open, save, and round-trip

何年も .xlsx を出力し続けてきた Delphi のレポート基盤に、新しい要件が舞い込む。ある公共セクターの顧客の調達ルールが OpenDocument Spreadsheet 形式の出力を義務付けており、その担当のアナリストたちは LibreOffice で保存した .ods ファイルとして編集結果を送り返してくる。こうして、同じコードが ODS を書き出し、かつ読み込む必要に迫られる。Delphi と C++Builder 向けの losLab のネイティブ Object Pascal スプレッドシートライブラリである HotXLS は、Excel も LibreOffice もどこにもインストールせずに、この両方向を処理できる。ただし、この 2 つの方向を対称にはしてくれない。エクスポートはインポートが回収できるよりもはるかに多くのものを運ぶため、それを前提にしてしまったチームは、顧客の改訂と次のレポートの間のどこかで、数式や書式がエラーも出ないまま消えていくのを目にすることになる

ODS のサポートは XLS 側ではなく XLSX ファサードに宿る

HotXLS は 1 つのパッケージの中に、独立した 2 つのクラス階層を同梱している。バイナリの BIFF8 .xls ファイル向けのユニット lxHandle にある TXLSWorkbook と、OOXML の .xlsx パッケージ向けのユニット lxHandleX にある TXLSXWorkbook である。OpenODSSaveAsODSGetODSSheetNames といった OpenDocument 関連のエントリポイントはすべて、TXLSXWorkbook にぶら下がっている。この配置は恣意的なものではない。OASIS ODF 1.3 で規定される ODS パッケージは、mimetype メンバー、マニフェスト、content.xml という本体を持つ zip アーカイブであり、これは構造的には OOXML の zip の親戚にあたる。一方 BIFF8 は 1990 年代のバイナリレコードストリームであり、両者に共通点は何もない

この配置には実用上の意味もある。レガシーな .xls ワークブックは、1 回の呼び出しで .ods になるわけではない。まず lxXlsxExport ユニットの SaveXLSWorkbookAsXLSX で BIFF の内容を XLSX モデルへ橋渡しし、その結果を TXLSXWorkbook で開き直してから、そこからエクスポートする。この橋渡しは無損失ではなく、それに頼る前に何が失われるかを知っておく価値がある。値、数式、数値書式、フォント、塗りつぶし、列幅はコピーされる。罫線、結合されたセル範囲、コメント、グラフ、条件付き書式は落とされる。書式を多用した .xls のソースは、出発時よりも簡素な見た目で ODS にたどり着くが、それはこの橋渡し処理の性質であって、ODS ライター自体の性質ではない

インポート側での検出は自動である。素の Open メソッドは mimetype メンバーによって ODS パッケージを認識し、そのメンバーが存在しない場合はトップレベルの content.xml の確認にフォールバックする。そのため、「ユーザーがアップロードしたものを何であれ開く」という汎用のコード経路も、拡張子を独自に判定する必要がない。開いた後は、SourceFormat プロパティがどちらの分岐が実行されたかを教えてくれる

Delphi における HotXLS クラス配置の図。すべての ODS エントリポイントが TXLSXWorkbook 上に存在し、SaveXLSWorkbookAsXLSX ブリッジが BIFF8 .xls コンテンツを運ぶ
OpenDocument のエントリーポイントはすべて TXLSXWorkbook にぶら下がり、レガシーの .xls が ODS に届くのは、可逆性のない BIFF から XLSX へのブリッジを通じてだけです

TODSExportOptions による ODS へのエクスポート

エクスポートの呼び出し自体は 1 行だが、その周りのオプションオブジェクトが、後でレビュー担当者が尋ねてくるような判断事項を担っている

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 は省略可能なものではなく必須である。単にラベル付けするだけでなく実際に出力を変える 2 つのプロパティは、じっくり見ておく価値がある。IncludeCharts := False の設定は、単にグラフを隠すだけではない。パッケージからグラフのサブ文書とそのマニフェストのエントリを丸ごと取り除く。消費者側がグラフにつまずいてしまうデータパイプラインである場合には、まさにこれが欲しい挙動である。Generator は ODF の meta:generator 文字列を上書きする。これを設定しなければ既定で HotXLS/<version> と表示される。下流のツールがファイルの生成元を指紋照合してサポートの振り分けに使っている場合は上書きしておくこと。そのどちらも当てはまらないなら、オプションオブジェクトそのものを省略してよい。SaveAs(FileName, xlsxOpenDocumentSpreadsheet) の呼び出しは、既定値のままの SaveAsODS と同じであり、どちらのストリームオーバーロードも、一時ファイルなしでパッケージを HTTP レスポンスへ直接書き込ませてくれる

インポート経路が読み取るもの、そして意図的に読み飛ばすもの

誰かに往復の忠実性を約束する前に、この部分は注意深く読んでほしい。HotXLS における ODS のインポートは、意図的に軽量な経路になっている。スカラーのセル値と、保存時に各数式が保持していたキャッシュ済みの結果は保持され、繰り返される行や列はグリッドへ展開される。スタイル、ODS の数式表現、図形は持ち越されない

数式に関する選択が最もつまずきやすい部分であり、これは意図的にそうなっている。ODF のセルは、隣り合わせで 2 つのものを保持している。ODF 1.3 Part 4 で定義される OpenFormula という方言で書かれた数式表現と、それを生成したアプリケーションが最後に計算した値である。OpenFormula を Excel の数式構文へ翻訳するのは、それ自体が独立した方言変換の問題であり、関数語彙、参照構文、エラーモデルをめぐる本物のエッジケースが伴う。代わりにキャッシュ済みの値を読むことで、その種の静かな誤変換の全体をまるごと回避できる。そのため、インポートされる数値は、送信者が最後に目にした数値そのものになる。代償は、それらが数値として届くだけで、その値を生み出した生きた数式としては届かないことである

そこから直接導かれる、設計時に想定しておくべき失敗のパターンがある。LibreOffice が最後に保存した時点では合計が正しかったスプレッドシートは、正しい数値のままインポートされるが、その数値はもはや定数になっている。入力セルを編集して再計算しても何も動かない。数式は消えており、その最終結果だけが残っている。インポート後も生きた数式が必要なワークフローであれば、XLSX ファサードでは先頭のイコール記号なしで式を受け取る Cell.Formula を通じて、自分のビジネスルールからプログラムでそれを再構築すること

非対称な往復を前提に設計する

エクスポートは、メモリ上の完全なワークブックモデルからレンダリングする。値、スタイル、そして要求すればグラフや画像もである。インポートが返すのは値だけである。したがって .xlsx から .ods への行程は忠実度が高いが、.ods から .xlsx への行程は値とキャッシュ済みの結果だけを持ち帰り、スタイルも生きた数式も戻ってこない。この 2 つを連鎖させると、非対称性は積み重なっていく。.xlsx から .ods を経て .xlsx へ戻る完全なサイクルは、行きにはすべてを忠実に書き出すが、帰りにはスタイルと数式を失う。しかも、どちらの段階でも何ら間違ったことは起きていない

Delphi からの非対称な HotXLS ODS ラウンドトリップの図。メモリ内ブックモデルからの完全忠実エクスポートと、数式を定数のまま残す値のみのインポート
エクスポートはメモリ内モデル全体を描きますが、インポートは値とキャッシュ済み結果を返すだけです。そのため .xlsx から .ods を経て .xlsx への完全な往復は、スタイルと生きた数式を黙って落とします
Book := TXLSXWorkbook.Create;
try
  Book.Open('vendor-revision.ods');          // 形式は自動検出される
  if Book.SourceFormat = xlsxOpenDocumentSpreadsheet then
  begin
    // ODSインポート後は値とキャッシュ済みの数式結果が存在し、
    // スタイルと生きた数式は存在しない。保存の前に、下流の
    // パイプラインが依存するものを再構築すること。
    Book.Sheets[0].Cells[2, 5].Formula := 'SUM(B2:D2)';
    Book.SaveAs('vendor-revision.xlsx');
  end;
finally
  Book.Free;
end;

ここから導かれるアーキテクチャ上のパターンは、受信した .ods ファイルをその場で編集する文書としてではなく、データフィードとして扱うことである。正典となるワークブックは .xlsx のまま保持し、顧客の改訂からは値だけを読み取り、その正典のコピーから必要に応じて新しい ODS を生成する。検証は両陣営にまたがる。エクスポートしたファイルは、リファレンスとなる ODF の消費者である LibreOffice Calc と、長年 ODS を読み続けてきたものの、グラフとスタイルのサポートの境界では LibreOffice と食い違う Excel の両方で開くこと。シート数、いくつかの主要なセル、グラフの有無を確認すれば、エクスポートプロファイルごとの十分なスモークチェックになる

インポートを確定する前に ODS ファイルをトリアージする

エンドポイントがアップロードを受け付ける場合、シート名を列挙するのは完全な解析よりはるかに安く済み、構造上の意外な事態を早期に捕まえられる

Delphi における HotXLS アップロードトリアージゲートの図。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 を返してリストをクリアするため、特定の 1 つの正の値と比較するのではなく <= 0 で判定すること。GetODSSheetNames はワークブックのインスタンスをリセットも初期化もしないため、1 つのプローブ用オブジェクトだけで、受信ファイルのディレクトリ全体を検証できる。このような構造上のチェックは、アナリストが改訂を送り返す前にシートの名前を変えたり削除したりするという、現実世界で最もよくある失敗を、まだファイル名と欠けているシート名を名指しできる入り口の段階で捕まえてくれる。3 層下でヌル参照として表面化させずに済む

これを土台にもっと広範な変換パイプラインを構築するのであれば、ワークブック監査と変換ワークベンチのパターンが、対象フォーマットを選ぶ前にファイルの機能を棚卸しする方法を示しており、大規模ワークブックの性能ガイドがバッチエクスポートを健全なメモリの範囲内に収める方法を扱っている

HotXLS は完全なソースコードを備えた、Delphi と C++Builder 向けのネイティブスプレッドシートライブラリである。完全な機能一覧とライセンスの詳細はHotXLS Delphi Component 製品ページに掲載している