技術記事

DelphiでExcelの文書プロパティを読む3つの経路

1万個のスプレッドシートを作成者、会社、最終更新日で振り分けるようパイプラインに求めたとき、最悪の手はブックを1つずつ完全に開くことです。答えはファイルの文書プロパティ、Officeの世界でDocument Summary Informationと呼ばれるものの中にあります。Windows Searchがインデックス化し、SharePointが整理に使い、Excelがプロパティダイアログに表示するメタデータ層です。この層はせいぜい数キロバイトで、しかもExcelの両方の形式で十分に文書化された場所に置かれています。要は、必要のない100万個のセルの代金を払わずに、Delphiからそこへ到達することです

現実的な経路は3つあり、違いは返す内容よりも、実行するマシンに要求するものの方に表れます。COMオートメーションはExcel本体を動かしてすべてを読みますが、値段はデスクトップ並みです。.xls形式はプロパティをOLEプロパティセットストリームに保持しており、Windowsが代わりに解析してくれます。.xlsx形式はzipの中の2つの小さなXMLパートに保持しており、Delphiのランタイムライブラリだけで開けます。以下、それぞれの動くコードと、その代償を包み隠さず示します

ExcelのDocument Summary Informationへ至るDelphiの3つの経路の図。Excel本体を動かすCOMオートメーション、xlsファイル向けのOLEプロパティセットストリーム、xlsxパッケージ向けのOOXML docProps XML解析
COMオートメーションはライセンス済みのデスクトップExcelとファイルあたり数秒という代価で完全な網羅性を買い、形式に寄り添う2つの経路はメタデータの容器だけをミリ秒で読みます。各経路が返すものはほぼ同じですが、ホストマシンに要求するものは同じではありません

経路1:COMオートメーションはすべてを読むが値段はデスクトップ並み

オートメーションは、1つのオブジェクトモデルで完全な網羅性を得られる唯一の経路です。標準のサマリー一式、CompanyとManagerを含む拡張一式、ユーザー定義のカスタムプロパティのすべてに、BuiltinDocumentPropertiesCustomDocumentPropertiesから手が届きます。値はすべてOleVariantとして届き、このAPIには痛い目に遭う前に知っておく価値のある癖が1つあります。一度も代入されたことのない組み込みプロパティは空で返るのではなく、Valueに触れた瞬間にEOleExceptionを発生させます。下のヘルパーはそれを失敗ではなく「未設定」として扱います

uses
  System.SysUtils, System.Variants, System.Win.ComObj;

procedure ReadPropertiesViaCom(const FileName: string);
var
  Excel, Book, Builtin, Custom: OleVariant;
  I: Integer;

  function BuiltinProp(const Name: string): string;
  begin
    try
      Result := VarToStr(Builtin.Item(Name).Value);
    except
      on EOleError do
        Result := '';   // プロパティは存在するが一度も代入されていない
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // 読み取り専用
    try
      Builtin := Book.BuiltinDocumentProperties;
      Writeln('Author : ', BuiltinProp('Author'));
      Writeln('Title  : ', BuiltinProp('Title'));
      Writeln('Subject: ', BuiltinProp('Subject'));
      Writeln('Company: ', BuiltinProp('Company'));
      Writeln('Manager: ', BuiltinProp('Manager'));

      Custom := Book.CustomDocumentProperties;
      for I := 1 to Custom.Count do
        Writeln(VarToStr(Custom.Item(I).Name), ' = ',
          VarToStr(Custom.Item(I).Value));
    finally
      Book.Close(False);
    end;
  finally
    Excel.Quit;   // どの経路でもここに到達させないとEXCEL.EXEが残る
    Excel := Unassigned;
  end;
end;

さて請求書です。このコードを走らせるすべてのマシンにExcelがインストールされていなければならず、それだけでほとんどのサーバーは除外されます。しかもMicrosoftのサポート方針は、Officeが無人のサーバーサイドオートメーション向けに設計もライセンスもされていないと明言しています。CreateOleObjectは完全なEXCEL.EXEを起動し、Workbooks.Openはブック全体を解析するので、最初のプロパティが返るまでにファイルあたりおよそ2秒から4秒を見込んでください。そしてQuitを囲むtry..finallyは飾りではありません。CreateOleObjectQuitの間で例外が抜け出すと、孤児になったEXCEL.EXEがファイルのロックを握ったまま残り、次の実行がそれにぶつかって失敗するまで目に見えません。1つのExcelインスタンスをバッチ全体で使い回せば起動コストは薄まりますが、リスクは集中します。隠れたデスクトップ上に迷子のダイアログが1つ出れば、その後ろに並んだすべてのファイルが止まるからです

経路2:.xlsはプロパティをOLEプロパティセットストリームに置く

BIFF8のブックはOLE複合ファイル、つまりストレージとストリームからなる小さなファイルシステムです。セルのデータはWorkbookストリームに入り、メタデータはその隣、名前が制御文字#5で始まる2つのプロパティセットストリームに入ります。古典的なフィールド用の\005SummaryInformationと、拡張フィールドおよびカスタムフィールド用の\005DocumentSummaryInformationです。それぞれの中にはMS-OLEPSの配置に従うバイナリのプロパティセットが座っており、セクションは書式識別子(FMTID)で、プロパティは整数のプロパティIDで引かれます。サマリーのセクションはFMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}で、PIDSI_TITLE$02PIDSI_AUTHOR$04です。Company($0F)とManager($0E)は文書サマリーのセクションにあり、カスタムプロパティは名前辞書の後ろに続く2つ目のセクションにあります

BIFF8のxls複合ファイルをDelphiの視点で分解した図。WorkbookストリームとSummaryInformationおよびDocumentSummaryInformationのプロパティセットを並べ、StgOpenStorageExからIPropertySetStorageへ至るアクセスの連鎖を示す
xlsファイルはセルのデータと文書プロパティを、OLE複合ファイルの中の兄弟のようなストリームとして格納します。バイナリのプロパティセットはWindowsが解析してくれるので、DelphiのコードはMS-OLEPSの配置にもコードページにも手で触れずに済みます

良い知らせは、Windowsではそのバイト列を自分で解析する必要が一切ないことです。構造化ストレージがIPropertySetStorageを通じてストリームを公開しており、次のコードは標準のランタイムユニットのままこの形でコンパイルできます

uses
  System.SysUtils, Winapi.Windows, Winapi.ActiveX, System.Win.ComObj;

const
  FMTID_SummaryInfo: TGUID = '{F29F85E0-4FF9-1068-AB91-08002B27B3D9}';
  PIDSI_TITLE    = $02;
  PIDSI_AUTHOR   = $04;
  STGFMT_STORAGE = 0;

function ReadXlsSummaryString(const FileName: string; PropId: TPropID): string;
var
  Unk: IUnknown;
  Stg: IStorage;
  PropSetStg: IPropertySetStorage;
  PropStg: IPropertyStorage;
  Spec: TPropSpec;
  Value: TPropVariant;
begin
  Result := '';
  OleCheck(StgOpenStorageEx(PWideChar(FileName),
    STGM_READ or STGM_SHARE_DENY_WRITE, STGFMT_STORAGE, 0, nil, nil,
    @IID_IStorage, Unk));
  Stg := Unk as IStorage;
  PropSetStg := Stg as IPropertySetStorage;
  OleCheck(PropSetStg.Open(FMTID_SummaryInfo,
    STGM_READ or STGM_SHARE_EXCLUSIVE, PropStg));
  Spec.ulKind := PRSPEC_PROPID;
  Spec.propid := PropId;
  if PropStg.ReadMultiple(1, @Spec, @Value) = S_OK then  // S_FALSE: 存在しない
  try
    case Value.vt of
      VT_LPSTR:  Result := string(AnsiString(Value.pszVal));
      VT_LPWSTR: Result := Value.pwszVal;
    end;
  finally
    PropVariantClear(Value);
  end;
end;

// 使い方: Writeln('Author: ', ReadXlsSummaryString('ledger.xls', PIDSI_AUTHOR));

このコード片が隠していることについて、正直に述べておきます。文字列はVT_LPWSTRで届くこともVT_LPSTRで届くこともあり、ANSIの場合そのバイト列はプロパティセット自身のコードページで符号化されています。そのコードページはセクションのプロパティ1として格納されているので、上のキャストが厳密に正しいのはそれがシステムのものと一致するときだけです。タイムスタンプはUTCのVT_FILETIMEで返ります。カスタムプロパティを読むということは、FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}のユーザー定義セクションを開き、その名前辞書をたどるということです。WindowsではIPropertyStorageがそのすべてを吸収してくれます。構造化ストレージのない環境向けにMS-OLEPSのパーサーを自作するのは、午後のひとときではなく本物のプロジェクトです

経路3:.xlsxはdocPropsをzipの中のXMLとして持つ

実際のところ、たいていのパイプラインが必要としているのはこの経路です。新しく作られるファイルは20年近く前から.xlsxだからです。OOXMLのブックはzipパッケージで、そのプロパティは用途ごとに小さなパートへ分かれています。docProps/core.xmlにはDublin Coreのフィールド、つまりdc:titledc:creatorcp:lastModifiedByに加え、UTCのW3CDTFタイムスタンプであるdcterms:createddcterms:modifiedが入ります。docProps/app.xmlにはCompanyやAppVersionといったアプリケーションレベルのフィールドが入り、docProps/custom.xmlにはカスタムプロパティが入ります。zipのセントラルディレクトリが各パートの位置を直接指しているので、ブックがどれほど大きくても読み取りにかかるのは数キロバイトだけです。TZipFileIXMLDocumentはどちらも同梱のランタイムライブラリにあり、この仕事を丸ごとこなします

Delphi向けのxlsx zipパッケージの構成図。ワークシートのパートと並ぶdocPropsのcore、app、customの各XMLメンバーを示し、任意のパートを探る手順と名前空間の照合規則を添えたもの
xlsxパッケージの大部分を占めるのはワークシートのデータですが、メタデータはその横にある3つの小さな任意メンバーに収まっています。zipのセントラルディレクトリ経由のランダムアクセスにより、読み取り量はブックではなくプロパティに比例します
uses
  System.SysUtils, System.Classes, System.Zip, Xml.XMLDoc, Xml.XMLIntf;

const
  NsDC    = 'http://purl.org/dc/elements/1.1/';
  NsTerms = 'http://purl.org/dc/terms/';
  NsCore  = 'http://schemas.openxmlformats.org/package/2006/metadata/core-properties';
  NsApp   = 'http://schemas.openxmlformats.org/officeDocument/2006/extended-properties';

function PartToXml(Zip: TZipFile; const PartName: string): IXMLDocument;
var
  Bytes: TBytes;
begin
  Zip.Read(PartName, Bytes);
  Result := LoadXMLData(TEncoding.UTF8.GetString(Bytes));
end;

function Field(const Doc: IXMLDocument; const LocalName, Ns: string): string;
var
  Node: IXMLNode;
begin
  Node := Doc.DocumentElement.ChildNodes.FindNode(LocalName, Ns);
  if Node <> nil then
    Result := Node.Text
  else
    Result := '';
end;

procedure ReadXlsxProperties(const FileName: string);
var
  Zip: TZipFile;
  Doc: IXMLDocument;
begin
  Zip := TZipFile.Create;
  try
    Zip.Open(FileName, zmRead);
    if Zip.IndexOf('docProps/core.xml') >= 0 then
    begin
      Doc := PartToXml(Zip, 'docProps/core.xml');
      Writeln('Title   : ', Field(Doc, 'title', NsDC));
      Writeln('Creator : ', Field(Doc, 'creator', NsDC));
      Writeln('Modifier: ', Field(Doc, 'lastModifiedBy', NsCore));
      Writeln('Modified: ', Field(Doc, 'modified', NsTerms));  // W3CDTF、UTC
    end;
    if Zip.IndexOf('docProps/app.xml') >= 0 then
    begin
      Doc := PartToXml(Zip, 'docProps/app.xml');
      Writeln('Company : ', Field(Doc, 'Company', NsApp));
      Writeln('App     : ', Field(Doc, 'Application', NsApp), ' ',
        Field(Doc, 'AppVersion', NsApp));
    end;
  finally
    Zip.Free;
  end;
end;

本番でこれを頑丈に保つ細部が2つあります。1つめ、これらのパートは任意です。docPropsを一切持たない最小限のパッケージもECMA-376のもとでは完全に妥当であり、だからこそコードは決めつけずにIndexOfで探りを入れています。2つめ、要素は上のFindNodeのようにローカル名と名前空間URIで照合し、決してリテラルの接頭辞では照合しないことです。dc:cp:はExcelの書き出し側の慣習にすぎず、他の生成器が作ったファイルは別の接頭辞を自由に選べます。環境面の注意を1つ。IXMLDocumentの既定のベンダーはMSXMLなので、コンソールアプリケーションやワーカースレッドではLoadXMLDataの前にCoInitializeを呼ばなければ、最初の解析がCOMのエラーで落ちます

コスト表、そしてライブラリが両方のパーサーに勝つとき

ごく普通の開発マシンで計測すると、オートメーションのセッションをファイルごとに作る場合のCOM経路はファイルあたりおよそ2秒から4秒に着地し、そのほとんどはEXCEL.EXEの起動とブック全体の解析です。しかも実行するあらゆる場所にインストール済みでライセンスされたExcelを必要とします。直接読む2つの経路はメタデータの容器だけを読み、ファイルあたり1桁ミリ秒で終わり、Delphiの実行ファイルが既にリンクしているもの以外には何のインストールも要りません。1万個のファイルを抱える共有フォルダー全体では、これは丸1日近くと1分未満との差であり、しかもOfficeの配備という問題が付いてきません

直接読む経路の難点は、それが2つあることです。両方の形式を受け付けるパイプラインは、互いに交わらない失敗の仕方を持つ2つのパーサーを保守することになります。一方にはコードページとPROPVARIANTの型、もう一方には名前空間と任意のパートがあり、どちらも相手の形式を読めません。この保守の負荷こそがネイティブライブラリを選ぶ理由になります。losLabがWindows上のDelphiとC++Builder向けに提供するObject Pascalのスプレッドシートライブラリ、HotXLSは、Title、Author、Company、Createdをはじめとする同じフィールドを素直なブックのプロパティとして公開し、.xlsでも.xlsxでも同じOpenがそれらを埋めます。Excelのインストールも、上に出てきた容器まわりの配管も要りません。メタデータだけの探りではなくブックを完全に開く一環としてプロパティを読むので、どのみちセルのデータにも触れるパイプラインによく合います。両方のファサードにおけるプロパティの全体像は、書き込み側も含めてHotXLSでExcelの文書プロパティを設定する記事で扱っています

注:Excelの完全な解析とメタデータ抽出のツールはHotXLS Delphi VCLコンポーネントで利用できます