技術記事

HotXLSでDelphiからExcel 2.0~4.0形式のファイルを読み込む

HotXLSは、Excel 2.0、3.0、4.0で作成されたワークブックを、Delphiおよび C++Builderから直接開きます。これらのファイルは、その後のあらゆる.xlsが使用するOLE複合文書コンテナよりも前の時代のものであるため、ストレージのラッパーを一切持たない生のBIFFレコードストリームであり、BIFF8向けに構築されたリーダーは、その中に見覚えのある構造を1つも見つけられません。開く際には、他のどのワークブックとも同じOpen呼び出しを使用します。リーダーが形式を検出し、内部で経路を切り替えます

これらのファイルは今でも出てくるからこそ、この話には意味があります。技術系のアーカイブ、政府の記録保存、1993年に書かれた制御ソフトウェアを持つ計測機器からの実験データ、長年稼働している会計システムなど、いずれもBIFF2やBIFF4のワークブックを残しています。最新のExcelは、セキュリティ上の理由でレガシーなコンバーターを削除しているため、これらの一部をまったく開こうとせず、誰もが持っているツールでは誰も読めないデータセットができてしまいます

OLE以前のワークブックの何が違うのか?

Excel 5.0以降のあらゆる.xlsはOLE2複合ファイル、つまりファイルの中にある小さなファイルシステムであり、ワークブックはWorkbookまたはBookという名前のストリームの中に存在します。これを解析するには、まずそのコンテナ自体を解析する必要があります。詳しくはPascalによる複合ファイルバイナリ形式で説明しています

BIFF2からBIFF4にはコンテナが存在しません。ファイルはいきなりBOFレコードから始まり、そのBOFのレコード番号が世代をエンコードしています。BIFF2は$0009、BIFF3は$0209、BIFF4は$0409です。HotXLSは、生のパスに進む前に、BOF本体の長さ(4バイトから6バイトの間)と、サブストリームの種類(ワークシートなら$0010、チャートなら$0020、マクロシートなら$0040)を検証します。この検証があるからこそ、破損したファイルや誤認識されたファイルが、非常に古いワークブックとして解釈されてしまうことを防げます

3つの世代、3つのレコードレイアウト

各世代が最も顕著に分かれるのはセルレコードです。BIFF2は、空白・整数・数値・ラベル・真偽値/エラーの各セルに対して、$0001から$0005までの連続した低いレコード番号を占めており、それぞれの本体は、後のバージョンが拡張書式インデックスを置く位置に3バイトの属性フィールドを持っています。BIFF3とBIFF4はそれを捨て、BIFF5のレコード番号とレイアウト($0201$0203$0204$0205)を、2バイトのXFインデックス付きで再利用しています

この最後の細部が、特定の、かつ誤診しやすい不具合を引き起こします。BIFF3またはBIFF4のLABELレコードは、行と列に続いて書式インデックス、そして文字数が続くという構造的にBIFF5の対応物と同一です。BIFF2のレイアウトを前提としたリーダーを書くと、2バイト分読み足りなくなり、レコードの末尾からはみ出して、それ以降のすべてを誤って解釈してしまいます。症状は例外ではありません。もっともらしいゴミの混じったワークブックとして読み込まれてしまうのです

数式レコードは、3世代すべてで並行した番号付けを持っています——$0006$0206$0406です。数式が文字列の結果を生む場合、その文字列は後続の別レコード($0007または$0207)に格納されます。BIFF2形式では、後の世代で使われる2バイトの長さプレフィックスではなく、1バイトの長さプレフィックスが使われます

なぜ数式はテキストではなく値として返ってくるのか

HotXLSは、これらのファイル内の数式のキャッシュされた計算結果を読み取り、数式の式そのものを復元しようとはしません。これは埋められるのを待っている欠落ではなく、意図的な境界線です

BIFF2からBIFF4における解析済みの式は、表面的な違いにとどまらないトークンエンコーディングをBIFF5以降と異なる形で使用しています。トークンの長さのプレフィックスの付け方が異なり、参照トークンのサイズも異なり、関数インデックステーブルは世代間で番号が振り直されています。それらのバイト列をBIFF8の式変換器に通しても、間違った数式ができるのではなく、まったくのでたらめな数式ができてしまいます。キャッシュされた値を読み取れば、Excelが最後に計算した数値や文字列が得られます。これこそが、アーカイブの移行作業が実際に必要としているものです

キャッシュされた値は、レコード内の世代依存のオフセットに存在します。BIFF2ではバイト7、BIFF3とBIFF4ではバイト6です。文字列、真偽値、エラー、空白といった特殊な値は、判別子付きの$FFFFというマーカーワードでエンコードされています。これは、後のBIFF世代も維持している同じ慣習です

開いてみる

呼び出し側のコードはこれといった特徴がなく、それこそが重要な点です。検出処理はOpenの内部で行われます:

uses
  lxHandle;

var
  Book: TXLSWorkbook;
  Sheet: TXLSWorksheet;
  R, C: Integer;
  V: Variant;
begin
  Book := TXLSWorkbook.Create;
  try
    if Book.Open('archive\1993-inventory.xls') <> 1 then
    begin
      Writeln('unreadable - quarantine for manual review');
      Exit;
    end;
    Sheet := Book.Sheets[1];          // Sheets[]は1始まり
    for R := Sheet.UsedRange.FirstRow + 1 to Sheet.UsedRange.LastRow + 1 do
      for C := Sheet.UsedRange.FirstCol + 1 to Sheet.UsedRange.LastCol + 1 do
      begin
        V := Sheet.Cells[R, C].Value;
        if not VarIsEmpty(V) then
          Writeln(Format('R%dC%d = %s', [R, C, VarToStr(V)]));
      end;
  finally
    Book.Free;
  end;
end;

このループ内のインデックス計算に注目してください。UsedRangeの境界はゼロ始まりですが、シートコレクションとセルアクセスはどちらも1始まりです。これは現行のAPIより前から存在する不整合であり、互換性のために維持されています。この調整を忘れると、間違った矩形範囲を対象に処理してしまい、しかも何ら異常なしとして報告されてしまいます。ファイルを一切読み込まずに済む安価な事前チェックについては、軽量なワークブック検査で説明しています

得られないもの、そしてその対処法

書式は解釈されません。HotXLSはこれらの世代のXFレコードやFONTレコードを解析しないため、フォント、色、罫線、数値書式は利用できず、かつてExcelが日付として表示していたセルは、生のシリアル値のまま返ってきます

最後の点は、リーダー側ではなく自分のコード側で処理する必要があります。その理由は正直なところ、BIFF2からBIFF4の数値書式は、日付かどうかを自動判定できるほど信頼できないからです。5桁の数字が並んだ列は、日付かもしれませんし、品番かもしれません。ワークブックの日付システムを使って意図的に変換してください。そのルールは日付シリアル値、1904年システム、数値書式で説明しています:

// 値ごとではなく列ごとに判断する: 5桁の数字は日付にも品番にも
// なり得るが、レガシーな書式からはそれを判断できない
if ColumnHoldsDates(C) then
begin
  // 2つの日付システムは1462日ずれているため、同じシリアル値が
  // 4年離れた2つの日付を指すことになる。決め打ちせず、
  // ワークブックからシステムを読み取ること
  if Book.Date1904 then
    Writeln(DateToStr(SerialToDate1904(V)))
  else
    Writeln(DateToStr(SerialToDate1900(V)));
end
else
  Writeln(VarToStr(V));

この全体像を締めくくる構造上の注意点が2つあります。パスワード保護とコードページのレコードは、ワークブックレベルのストリームではなく、単一のワークシートストリームの内部に現れます。それらを置くべきワークブックレベルのストリームが存在しないためであり、そのため、これらはワークシートの文脈の中で認識しなければなりません。また、BIFF2からBIFF4のファイルには、ちょうど1つのシートサブストリームしか含まれません。複数シートのワークブックは、フォーマットがコンテナを獲得するまで存在しませんでした

したがって、現実的な移行の道筋は2段階になります。まずレガシーなファイルから値を読み取り、次にそれらの値を、自分で適用した書式とともに保持する最新のワークブックとして書き出します。レガシーな読み込み、最新の書き込み、そしてその間のすべては、Delphiおよび C++Builder向けの1つのライブラリの中で動作します。詳しくはHotXLS Delphiスプレッドシートコンポーネントページに掲載されています