Technischer Artikel

Excel-Dokumenteigenschaften in Delphi lesen: drei Wege

Bitten Sie eine Pipeline, zehntausend Tabellen nach Autor, Firma oder Änderungsdatum zu verteilen, und das Schlimmste, was sie tun kann, ist, jede Arbeitsmappe vollständig zu öffnen. Die Antworten reisen in den Dokumenteigenschaften der Datei mit, in der Office-Welt Document Summary Information genannt: jene Metadatenschicht, die Windows Search indexiert, nach der SharePoint ablegt und die Excel in seinem Eigenschaftendialog zeigt. Diese Schicht ist höchstens ein paar Kilobyte groß und liegt in beiden Excel-Formaten an einer gut dokumentierten Stelle. Der Kniff besteht darin, sie aus Delphi zu erreichen, ohne für die Million Zellen zu bezahlen, die Sie nicht brauchen

Es gibt drei echte Wege, und sie unterscheiden sich weniger darin, was sie zurückgeben, als darin, was sie der ausführenden Maschine abverlangen. Die COM-Automatisierung steuert Excel selbst und liest alles, zu Desktop-Preisen. Das Format .xls hält seine Eigenschaften in OLE-Property-Set-Streams, die Windows für Sie zerlegt. Das Format .xlsx hält sie in zwei kleinen XML-Teilen innerhalb eines ZIP-Pakets, das die Delphi-RTL allein öffnen kann. Lauffähiger Code für jeden Weg folgt, mit offen benannten Kosten

Übersicht der drei Delphi-Wege zur Document Summary Information von Excel: COM-Automatisierung mit Excel selbst, OLE-Property-Set-Streams für xls-Dateien und das Parsen der docProps-XML in xlsx-Paketen
Die COM-Automatisierung erkauft vollständige Abdeckung mit einem lizenzierten Desktop-Excel und Sekunden pro Datei, während die beiden formateigenen Wege in Millisekunden nur die Metadatencontainer lesen. Was die Wege zurückgeben, ist fast dasselbe — was sie vom Rechner verlangen, nicht

Weg 1: COM-Automatisierung liest alles, zu Desktop-Preisen

Die Automatisierung ist der einzige Weg mit vollständiger Abdeckung über ein einziges Objektmodell: der klassische Summary-Satz, der erweiterte Satz mit Company und Manager sowie benutzerdefinierte Eigenschaften, alle erreichbar über BuiltinDocumentProperties und CustomDocumentProperties. Alles kommt als OleVariant an, und die API hat eine Eigenheit, die Sie kennen sollten, bevor sie zubeißt: Eine eingebaute Eigenschaft, der nie ein Wert zugewiesen wurde, kommt nicht leer zurück, sondern löst in dem Moment eine EOleException aus, in dem Sie Value berühren. Der folgende Helfer behandelt das als „nicht gesetzt“ und nicht als Fehlschlag

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 := '';   // Eigenschaft existiert, wurde aber nie zugewiesen
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // schreibgeschützt
    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;   // auf jedem Pfad erreichen, sonst bleibt EXCEL.EXE zurück
    Excel := Unassigned;
  end;
end;

Nun zur Rechnung. Excel muss auf jeder Maschine installiert sein, auf der dieser Code läuft, was die meisten Server schon von sich aus ausschließt, und die Support-Richtlinie von Microsoft sagt ausdrücklich, dass Office für unbeaufsichtigte serverseitige Automatisierung weder ausgelegt noch lizenziert ist. CreateOleObject startet eine vollständige EXCEL.EXE und Workbooks.Open zerlegt die ganze Arbeitsmappe, rechnen Sie also mit etwa zwei bis vier Sekunden pro Datei, bevor die erste Eigenschaft zurückkommt. Und das try..finally um Quit ist keine Zierde: Eine Ausnahme, die zwischen CreateOleObject und Quit entkommt, hinterlässt eine verwaiste EXCEL.EXE, die eine Sperre auf der Datei hält, unsichtbar bis der nächste Lauf daran scheitert. Eine Excel-Instanz über einen ganzen Stapel wiederzuverwenden, verteilt die Startkosten, bündelt aber das Risiko, denn ein einziger verirrter Dialog auf dem verborgenen Desktop bringt jede dahinter wartende Datei zum Stillstand

Weg 2: .xls legt Eigenschaften in OLE-Property-Set-Streams ab

Eine BIFF8-Arbeitsmappe ist eine OLE-Verbunddatei, ein Miniaturdateisystem aus Storages und Streams. Die Zelldaten liegen im Stream Workbook; die Metadaten liegen daneben in zwei Property-Set-Streams, deren Namen mit dem Steuerzeichen #5 beginnen: \005SummaryInformation für die klassischen Felder und \005DocumentSummaryInformation für die erweiterten und benutzerdefinierten. In jedem steckt ein binäres Property Set im MS-OLEPS-Layout, mit Abschnitten, die über eine Formatkennung (FMTID) adressiert werden, und Eigenschaften, die über eine ganzzahlige Property-ID adressiert werden. Der Summary-Abschnitt hat die FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, in der PIDSI_TITLE gleich $02 und PIDSI_AUTHOR gleich $04 ist; Company ($0F) und Manager ($0E) liegen im Document-Summary-Abschnitt, und benutzerdefinierte Eigenschaften in einem zweiten Abschnitt hinter einem Namenswörterbuch

Delphi: Aufbau einer BIFF8-xls-Verbunddatei mit dem Workbook-Stream neben den Property Sets SummaryInformation und DocumentSummaryInformation sowie der Zugriffskette von StgOpenStorageEx zu IPropertySetStorage
Eine xls-Datei legt Zelldaten und Dokumenteigenschaften als benachbarte Streams in einer OLE-Verbunddatei ab. Windows zerlegt die binären Property Sets für Sie, sodass Delphi-Code weder MS-OLEPS-Layouts noch Codepages von Hand anfasst

Die gute Nachricht ist, dass Sie diese Bytes unter Windows nie selbst zerlegen. Structured Storage legt die Streams über IPropertySetStorage offen, und das Folgende übersetzt sich so, wie es dasteht, gegen die mitgelieferten RTL-Units

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: nicht vorhanden
  try
    case Value.vt of
      VT_LPSTR:  Result := string(AnsiString(Value.pszVal));
      VT_LPWSTR: Result := Value.pwszVal;
    end;
  finally
    PropVariantClear(Value);
  end;
end;

// Verwendung: Writeln('Author: ', ReadXlsSummaryString('ledger.xls', PIDSI_AUTHOR));

Ein ehrliches Wort dazu, was der Ausschnitt verschweigt. Zeichenketten können als VT_LPWSTR oder als VT_LPSTR eintreffen, und im ANSI-Fall sind die Bytes in der eigenen Codepage des Property Sets kodiert, die selbst als Eigenschaft 1 des Abschnitts abgelegt ist, sodass die obige Umwandlung nur dann exakt ist, wenn diese Codepage zur Systemcodepage passt. Zeitstempel kommen als VT_FILETIME in UTC zurück. Benutzerdefinierte Eigenschaften bedeuten, den benutzerdefinierten Abschnitt mit der FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE} zu öffnen und sein Namenswörterbuch abzulaufen. IPropertyStorage nimmt Ihnen das unter Windows alles ab; einen eigenen MS-OLEPS-Parser für eine Umgebung ohne Structured Storage zu schreiben, ist ein echtes Projekt und kein Nachmittag

Weg 3: .xlsx hält docProps als XML im ZIP-Paket

Das ist der Weg, den die meisten Pipelines tatsächlich brauchen, denn neue Dateien sind seit fast zwei Jahrzehnten .xlsx. Eine OOXML-Arbeitsmappe ist ein ZIP-Paket, und ihre Eigenschaften sind nach Zweck auf kleine Teile verteilt: docProps/core.xml hält die Dublin-Core-Felder dc:title, dc:creator und cp:lastModifiedBy sowie dcterms:created und dcterms:modified als W3CDTF-Zeitstempel in UTC, docProps/app.xml hält Felder auf Anwendungsebene wie Company und AppVersion, und docProps/custom.xml hält die benutzerdefinierten Eigenschaften. Weil das zentrale Verzeichnis des ZIP-Archivs jeden Teil direkt findet, kostet ihr Lesen ein paar Kilobyte, gleich wie groß die Arbeitsmappe ist. TZipFile und IXMLDocument, beide in der mitgelieferten RTL, erledigen die ganze Arbeit

Delphi: Aufbau eines xlsx-ZIP-Pakets mit den XML-Teilen docProps core, app und custom neben den Arbeitsblattteilen, samt den Produktionsregeln zum Prüfen optionaler Teile und zum Abgleich der Namensräume
Die Arbeitsblattdaten beherrschen ein xlsx-Paket, doch die Metadaten sitzen in drei kleinen optionalen Teilen daneben. Der wahlfreie Zugriff über das zentrale ZIP-Verzeichnis hält den Lesevorgang proportional zu den Eigenschaften, nicht zur Arbeitsmappe
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;

Zwei Details halten das im Produktivbetrieb robust. Erstens sind die Teile optional: Ein minimales Paket ganz ohne docProps ist nach ECMA-376 völlig gültig, weshalb der Code mit IndexOf prüft, statt etwas anzunehmen. Zweitens gleichen Sie Elemente über lokalen Namen und Namensraum-URI ab, wie FindNode es oben tut, niemals über das wörtliche Präfix; dc: und cp: sind Konventionen des Schreibers von Excel, und Dateien anderer Erzeuger dürfen andere Präfixe wählen. Eine Anmerkung zur Umgebung: Der Standardanbieter hinter IXMLDocument ist MSXML, daher muss eine Konsolenanwendung oder ein Worker-Thread vor LoadXMLData ein CoInitialize aufrufen, sonst stirbt das erste Parsen an einem COM-Fehler

Die Kostenaufstellung, und wann eine Bibliothek beide Parser schlägt

Auf einer gewöhnlichen Entwicklermaschine gemessen, landet der COM-Weg bei etwa zwei bis vier Sekunden pro Datei, wenn die Automatisierungssitzung je Datei erzeugt wird, fast alles davon Start der EXCEL.EXE plus vollständiges Zerlegen der Arbeitsmappe, und er verlangt überall ein installiertes, lizenziertes Excel. Die beiden direkten Wege lesen nur die Metadatencontainer, sind in einstelligen Millisekunden pro Datei fertig und brauchen nichts Installiertes über das hinaus, was eine Delphi-Anwendung ohnehin einbindet. Über eine Freigabe mit zehntausend Dateien ist das der Unterschied zwischen dem größten Teil eines Arbeitstags und unter einer Minute, ganz ohne Office-Verteilungsfrage

Der Haken an den direkten Wegen ist, dass es zwei davon gibt. Eine Pipeline, die beide Formate annimmt, pflegt zwei Parser mit zwei getrennten Fehlerbildern, Codepages und PROPVARIANT-Typen auf der einen Seite, Namensräume und optionale Teile auf der anderen, und keiner liest das Format des anderen. Diese Pflegelast ist das Argument für eine native Bibliothek: HotXLS, die Object-Pascal-Tabellenbibliothek von losLab für Delphi und C++Builder unter Windows, legt dieselben Felder als schlichte Arbeitsmappeneigenschaften offen, Title, Author, Company, Created und den Rest, gefüllt von Open für .xls und .xlsx gleichermaßen, ohne Excel-Installation und ohne die ganze Containerklempnerei von oben. Sie liest die Eigenschaften als Teil eines vollständigen Öffnens der Arbeitsmappe und nicht als reine Metadatenprobe, passt also zu Pipelines, die ohnehin an die Zelldaten gehen; die vollständige Eigenschaftsfläche auf beiden Fassaden, samt Schreibseite, behandelt unser Artikel zum Setzen von Excel-Dokumenteigenschaften mit HotXLS

Hinweis: Werkzeuge für vollständiges Excel-Parsen und Metadatenextraktion finden Sie in der HotXLS Delphi VCL Component