技術記事

Delphi で XLSB バイナリワークブックを読み書きする

HotXLS は、Excel Binary Workbook(XLSB)ファイルを、XLSX に使うのと同じ TXLSXWorkbook ファサードで Delphi から読み書きします。Open はパッケージの内容からバイナリワークブックを検出し、SaveAs か SaveAsChecked に xlsxExcelBinaryWorkbook を指定すれば書き出します。バックエンドは検証済みのサブセットをカバーし、表現できないものは出力先に触れる前に拒否します

最後の節こそ、ゆっくり読む価値のある部分です。XLSB は XLSX の単純な変種に見え、パッケージの用語では実際その通りです。厄介なのは、2 つのフォーマットが違う符号化をする細部と、片方をもう片方へ橋渡しするときにライブラリが黙って仮定することに住んでいます。このバックエンドで最も教訓的だったバグは、不正なレコードではまったくありませんでした。消えた既定フォントと、値も書式コードも列幅もすべて正しいのに Excel で #### になった日付列です

XLSB ファイルとは何か、XLSX とどう違うのか

XLSB ファイルは、XLSX ワークブックとまったく同じパート構造を持つ OPC パッケージですが、ワークブック、ワークシート、スタイル、共有文字列の各パートが XML の代わりにバイナリレコードを格納します。フォーマットは [MS-XLSB] で規定され、レコード符号化は BIFF12 と呼ばれることが多いです。[Content_Types].xml、リレーションシップパート、ZIP コンテナは依然あります。変わるのは、xl/workbook.bin が xl/workbook.xml を置き、xl/worksheets/sheet1.bin がワークシート XML を置く、という具合です

各 BIFF12 レコードは、コンパクトな可変長ヘッダで始まります。レコードタイプは 1 か 2 バイト、バイトごとに 7 ビットで上位ビットが継続フラグなので、レコードタイプは 14 ビットに収まります。ペイロードサイズは同じ 7 ビット方式で 1 から 4 バイト続きます。文字列は 32 ビットの文字数に UTF-16 コードユニットが続きます。BIFF8 と比べると、ずっときれいなレイアウトです。8224 バイトの CONTINUE 分割もなく、レコード途中の compressed/wide 文字列フラグもありません。XLSX と比べると XML のトークン化を完全に省くため、大きな XLSB ファイルはディスク上で小さくなりがちで、Excel のオープンも速いのです

検出コードをつまずかせるパッケージの細部が 1 つあります。Excel は XLSB ワークブックパートを、マクロをまったく含まないワークブックであっても、コンテンツタイプ application/vnd.ms-excel.sheet.binary.macroEnabled.main で登録します。HotXLS はまず [Content_Types].xml の登録を確認し、コンテンツタイプが不在のときに限って .bin パート拡張子へフォールバックします。どちらの経路も、厳格なレコードバリデータを通らねばなりません。パート名、リレーションシップ、コンテンツタイプに関する一般的な規則は、Excel が正当な XLSX を修復する理由とその背後の OPC パッケージ規則で扱ったのと同じものです

XLSX より XLSB を選ぶべきはどんなときか

下流のシステムが要求するとき、あるいはオープン時間を気にする Excel ユーザーと大きな数値 heavy のワークブックをやり取りするときは XLSB を選び、それ以外では XLSX がより安全な既定であり続けます。XLSX は ISO 規格(ISO/IEC 29500)で、ツールのサポートは広く、ZIP ツールとテキストエディタで検査でき、すべてのスプレッドシート製品が読みます。XLSB は公開された仕様を持つ Microsoft フォーマットですが、エコシステムは狭めです

実務では、要求は 2 つの方向から来ます。一部のレポーティングパイプラインとアーカイブは何年も前に .xlsb を標準化し、他は何も受け付けません。別のチームは数十万の数値セルを持つワークブックを生成し、バイナリ版のほうがユーザーのオープンが目に見えて速いと確認しました。どちらも正当な理由です。「効率が良さそうだから」は正当ではありません。HotXLS のバイナリバックエンドは完全なパッケージをメモリにステージするため、メモリ上限のあるサーバーエクスポートの道具ではないからです。そちらには、ストリーミング XLSX ライターが正解です

TXLSXWorkbook で XLSB を開き保存する

TXLSXWorkbook.Open にはフォーマットのヒントが要りません。同じ呼び出しが XLSX、XLSM、XLSB を読み込み、バイナリの読み込みが成功した後は TXLSXWorkbook.SourceFormat が xlsxExcelBinaryWorkbook を報告します。書き込みは明示的です。フォーマットなしの SaveAs(FileName) は、ファイル名が .xlsb で終わっていても、常に Open XML ワークブックを生みます。バイナリ出力では常にフォーマットを名指しする、ということです

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 メソッドへ経路付けられます。直接呼ぶことはありません。ファイル出力は兄弟の一時ファイルへステージされ、最後の 1 回の置き換えでコミットされます。ストリームの行き先は読み取り可能でシーク可能でなければなりません。ライターはそのバイト列、サイズ、位置をスナップショットし、最終コピーが失敗したら復元するからです。バイナリ保存の後、TXLSXWorkbook.LastSaveUsedStreaming は False を報告します。これは正確です。1 バイト目が出力先へ届く前に、パッケージ全体がメモリに存在するのです

完全な忠実さではなく検証済みサブセットなのはなぜか

HotXLS は XLSB のために 2 つ目のオブジェクトモデルを維持しません。オープン時、XlsbToOpenXml がバイナリパッケージをメモリ内の Open XML パッケージへ変換し、普通の XML パーサがそれを読みます。保存時、XML ライターがパッケージを組み立て、OpenXmlToXlsb がそれをバイナリレコードとして符号化します。したがってすべての機能は 2 回の翻訳を生き延びねばならず、ブリッジが正確に翻訳できない機能は、近似ではなく拒否されます

HotXLS 2.384.113 時点のサポート範囲は、ほとんどのデータワークブックには十分に広いです。型付きの値、共有とインラインの文字列、一般的なフォント、塗り、罫線、数値書式、行の高さと列の幅、キャッシュ結果付きの普通のセル数式、定義名、結合セル、ハイパーリンク、ウィンドウ枠の固定と分割、単純なカスタム条件付きの AutoFilter 範囲、そして普通のワークシートテーブルです。その外側には、共有数式と配列数式、future 関数とユーザー定義関数、外部ワークブック参照、チャートと図形、PivotTable とスライサー、VBA、条件付き書式、データの入力規則、手動計算や反復計算のような既定外の計算設定が座っています

拒否は必須であって、助言ではありません。TXLSXWorkbook.GetConversionReport(xlsConversionExcelBinaryWorkbook) は保存前に未対応機能を列挙し、それらのエントリは AAllowLossy = True を渡しても、チェックなしの SaveAs を使っても、保存を失敗させます。これは意図的です。バイナリコピーから PivotTable を黙って落とすのは、ワークブックがクライアントの前に出るまで誰も気づかない種類の損失です

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 スタイルリーダーが 2 つの仮定を置いていたからです。HotXLS 自身が書くファイルには当てはまるけれど、Excel が書くワークブックには当てはまらない仮定です。ブリッジは両方のリソースを完璧に保っていました。損失は 1 層上、公開の Open 経路で起こりました。この隙間がこのバックエンドの最大の教訓です。正しいフォーマットブリッジは、忠実な公開ラウンドトリップを保証しません

最初の仮定は fonts[0] に関するものでした。HotXLS が XLSX を保存するとき、暗黙の既定フォントをスロット 0 に書き込み、XML リーダーはそのスロットをスキップします。ユーザーから見える Fonts コレクションがインデックス 0 から始まるようにするためです。Excel が書いた XLSB はそのスロットに自分の明示的な既定を運んでおり、スキップはこの選択を捨てていました。2 つ目の仮定は数値書式に関するものでした。リーダーはカスタムの numFmtId 値(164 以上)だけを NumberFormats へ写像し、組み込みの id はゼロのままでした。組み込み書式 2、つまり 0.00 を運ぶスタイル付きの空白セルは、General として戻ってきました

修正は完全にバイナリ取り込み経路の中にあります。完全なパッケージがプリフライトを通った後、XLSB のオープンは、同期 XML パースのためだけにプライベートなスタイルモードを設定します。このモードでは、リーダーはスロット 0 を含むすべてのフォントを読み込み、各 fontId を 1 だけずらし、サポートされる各組み込み 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 はパッケージのプリフライト中に拒否されます。HotXLS は id 0 から 22、37 から 40、45 から 49 を、0.00、0%、mm:ss、@ といった既知のコードへ写像します。164 未満の残りの id は、レンダリングが Excel インストールの地域設定に依存する書式に属し、取り込みは「ロケール依存の数値書式には明示的な書式コードが要る」というメッセージとともにこれらを拒みます

それらの id のためにコードをでっち上げるのは、失敗するより悪いです。推測された東アジアの日付パターンは、もっともらしく見え、エラーなしでラウンドトリップし、ユーザーの半分に間違ったものを表示します。タイミングも重要です。チェックは全パッケージのプリフライトで、既存のワークブックがクリアされる前に走るため、拒否されたファイルは、以前に読み込まれたモデル、ファイル名、ソースフォーマットをそのままにします。組み込み日付 id が 1900 と 1904 の日付システムとどう絡むかは、Excel の日付シリアルと numFmt の記事が扱います

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 のリグレッションはどちらもブリッジのテストを通過し、どちらも Open と SaveAsChecked で開いて再保存した Excel 製のワークブックですぐに現れました

FAQ

HotXLS は XLSB ファイルを自動検出するのか

はい。TXLSXWorkbook.Open はワークブックパートに登録されたコンテンツタイプを検査し、バイナリレコードを検証します。同じ呼び出しが XLSX と XLSB を読み込み、SourceFormat がどちらを読んだかを報告します

.xlsb ファイル名の SaveAs がそれでも XLSX を書くのはなぜか

HotXLS は拡張子から出力フォーマットを推測することは決してありません。バイナリワークブックを書くには、SaveAs か SaveAsChecked に xlsxExcelBinaryWorkbook を渡します

ワークブックが XLSB サブセット外の機能を使うとどうなるのか

損失ありの保存を許していても、保存は出力先が変わる前に失敗します。GetConversionReport が妨げになる機能を事前に列挙し、Diagnostics が後から失敗を説明します

XLSB サポート、変換レポート、ワークブック API の残りは、Delphi と C++Builder 向けのネイティブスプレッドシートコンポーネント HotXLS に搭載されています