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
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
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
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