Tehnički članak

Čitanje svojstava Excel dokumenta u Delphiju: tri puta

Zatražite od cjevovoda da deset tisuća proračunskih tablica razvrsta po autoru, tvrtki ili datumu zadnje izmjene i najgore što može učiniti jest svaku radnu knjigu otvoriti u cijelosti. Odgovori putuju u svojstvima dokumenta, onome što svijet Officea zove Document Summary Information: sloj metapodataka koji Windows Search indeksira, po kojem SharePoint razvrstava i koji Excel prikazuje u svom dijalogu Properties. Taj sloj teži najviše nekoliko kilobajta i u oba Excelova formata leži na dobro dokumentiranom mjestu. Trik je do njega doprijeti iz Delphija bez plaćanja milijuna ćelija koje vam ne trebaju

Stvarna su tri puta i razlikuju se manje po tome što vraćaju, a više po tome što traže od stroja koji ih izvodi. COM automatizacija pogoni sam Excel i čita sve, po cijeni radne stanice. Format .xls svoja svojstva drži u OLE tokovima skupova svojstava koje će Windows parsirati umjesto vas. Format .xlsx ih drži u dvama malim XML dijelovima unutar zip arhive koju Delphi RTL može otvoriti sam. Slijedi radni kod za svaki, uz otvoreno navedene troškove

Prikaz triju Delphi putova do Excel zapisa Document Summary Information: COM automatizacija koja pogoni sam Excel, OLE tokovi skupova svojstava za xls datoteke i parsiranje OOXML docProps XML-a za xlsx pakete
COM automatizacija kupuje potpunu pokrivenost po cijeni licenciranog stolnog Excela i sekundi po datoteci, dok dva puta izvorna formatu čitaju samo spremnike metapodataka u milisekundama. Ono što svaki put vraća gotovo je isto — ono što traži od domaćina nije

Put 1: COM automatizacija čita sve, po cijeni radne stanice

Automatizacija je jedini put s potpunom pokrivenošću kroz jedan objektni model: standardni skup sažetka, prošireni skup s poljima Company i Manager te korisnički definirana prilagođena svojstva, sve dohvatljivo kroz BuiltinDocumentProperties i CustomDocumentProperties. Sve stiže kao OleVariant, a API ima jednu naviku koju vrijedi znati prije nego što ugrize: ugrađeno svojstvo koje nikada nije dodijeljeno ne vraća se prazno, nego podigne EOleException u trenutku kada dotaknete Value. Pomoćna funkcija u nastavku to tretira kao „nije postavljeno“, a ne kao neuspjeh

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 := '';   // svojstvo postoji, ali nikada nije dodijeljeno
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // samo za čitanje
    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;   // dosegni ovo na svakom putu, inače EXCEL.EXE ostaje iza
    Excel := Unassigned;
  end;
end;

Sada račun. Excel mora biti instaliran na svakom stroju na kojem se ovaj kod izvodi, što samo po sebi isključuje većinu poslužitelja, a Microsoftova je politika podrške izričita da Office nije ni zamišljen ni licenciran za automatizaciju bez nadzora na strani poslužitelja. CreateOleObject pokreće cijeli EXCEL.EXE, a Workbooks.Open parsira cijelu radnu knjigu, pa očekujte otprilike dvije do četiri sekunde po datoteci prije nego što se vrati prvo svojstvo. I try..finally oko poziva Quit nije ukras: iznimka koja pobjegne između CreateOleObject i Quit ostavlja siroti EXCEL.EXE koji drži zaključanu datoteku, nevidljiv sve dok se sljedeće pokretanje o njega ne spotakne. Ponovna upotreba jedne instance Excela kroz cijelu seriju amortizira trošak pokretanja, ali koncentrira rizik, jer jedan zalutali dijalog na skrivenoj radnoj površini zaustavi svaku datoteku iza sebe u redu

Put 2: .xls svojstva pohranjuje u OLE tokovima skupova svojstava

BIFF8 radna knjiga OLE je složena datoteka, minijaturni datotečni sustav spremišta i tokova. Podaci ćelija žive u toku Workbook; metapodaci žive uz njega u dvama tokovima skupova svojstava čiji nazivi počinju upravljačkim znakom #5: \005SummaryInformation za klasična polja i \005DocumentSummaryInformation za proširena i prilagođena. Unutar svakog sjedi binarni skup svojstava u rasporedu MS-OLEPS, s odjeljcima ključanima identifikatorom formata (FMTID) i svojstvima ključanima cjelobrojnom oznakom svojstva. Odjeljak sažetka nosi FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, u kojemu je PIDSI_TITLE jednak $02, a PIDSI_AUTHOR jednak $04; Company ($0F) i Manager ($0E) žive u odjeljku sažetka dokumenta, a prilagođena svojstva u drugom odjeljku iza rječnika naziva

Delphi anatomija BIFF8 xls složene datoteke koja tok Workbook smješta uz skupove svojstava SummaryInformation i DocumentSummaryInformation, s lancem pristupa od StgOpenStorageEx do IPropertySetStorage
Datoteka xls podatke ćelija i svojstva dokumenta pohranjuje kao susjedne tokove u OLE složenoj datoteci. Windows će binarne skupove svojstava parsirati umjesto vas, pa Delphi kod ne dira ni MS-OLEPS rasporede ni kodne stranice ručno

Dobra je vijest da te bajtove na Windowsima nikada ne parsirate sami. Strukturirano spremište tokove izlaže kroz IPropertySetStorage, a sljedeći se kod prevodi upravo ovako uz standardne RTL jedinice

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

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

Poštena riječ o onome što isječak prešućuje. Nizovi znakova mogu stići kao VT_LPWSTR ili kao VT_LPSTR, a u ANSI slučaju bajtovi su kodirani kodnom stranicom samog skupa svojstava, koja je pak pohranjena kao svojstvo 1 tog odjeljka, pa je gornja pretvorba točna samo kada se ta kodna stranica poklapa sa sustavskom. Vremenske oznake vraćaju se kao VT_FILETIME u UTC-u. Prilagođena svojstva znače otvaranje korisnički definiranog odjeljka, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, i prolazak kroz njegov rječnik naziva. IPropertyStorage sve to na Windowsima upija; pisanje vlastitog MS-OLEPS parsera za okruženje bez strukturiranog spremišta pravi je projekt, a ne posao za jedno poslijepodne

Put 3: .xlsx docProps drži kao XML unutar zip arhive

To je put koji većini cjevovoda uistinu treba, jer nove su datoteke .xlsx već gotovo dva desetljeća. OOXML radna knjiga zip je paket, a njezina su svojstva po namjeni razdijeljena na male dijelove: docProps/core.xml nosi polja iz standarda Dublin Core, dc:title, dc:creator, cp:lastModifiedBy, uz dcterms:created i dcterms:modified kao W3CDTF vremenske oznake u UTC-u, dok docProps/app.xml nosi polja na razini aplikacije poput Company i AppVersion, a docProps/custom.xml nosi prilagođena svojstva. Budući da središnji direktorij zip arhive svaki dio nalazi izravno, njihovo čitanje stoji nekoliko kilobajta bez obzira na to koliko je radna knjiga velika. TZipFile i IXMLDocument, oboje u isporučenom RTL-u, obavljaju cijeli posao

Delphi: raspored xlsx zip paketa koji prikazuje članove docProps core, app i custom XML uz dijelove radnih listova, s proizvodnim pravilima za ispitivanje neobveznih dijelova i podudaranje imenskih prostora
Podaci radnih listova prevladavaju u xlsx paketu, a metapodaci ipak sjede u tri mala neobvezna člana pokraj njih. Izravan pristup preko središnjeg direktorija zip arhive drži čitanje razmjernim svojstvima, a ne radnoj knjizi
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;

Dva detalja ovo drže otpornim u proizvodnji. Prvo, dijelovi su neobvezni: minimalan paket bez ijednog docProps potpuno je valjan prema ECMA-376, i zato kod provjerava pozivom IndexOf umjesto da pretpostavlja. Drugo, elemente podudarajte po lokalnom nazivu i URI-ju imenskog prostora, kako to gore radi FindNode, a nikada po doslovnom prefiksu; dc: i cp: konvencije su Excelova zapisivača, a datoteke drugih generatora slobodno biraju druge prefikse. Jedna napomena o okruženju: podrazumijevani dobavljač za IXMLDocument jest MSXML, pa konzolna aplikacija ili radna dretva mora pozvati CoInitialize prije poziva LoadXMLData, inače prvo parsiranje umre uz COM pogrešku

Račun troškova i kada biblioteka pobjeđuje oba parsera

Mjereno na običnom razvojnom stroju, COM put slijeće na otprilike dvije do četiri sekunde po datoteci kada se sesija automatizacije stvara za svaku datoteku, gotovo sve od toga na pokretanje EXCEL.EXE i potpuno parsiranje radne knjige, i traži instaliran, licenciran Excel svugdje gdje se izvodi. Dva izravna puta čitaju samo spremnike metapodataka, završe u jednoznamenkastom broju milisekundi po datoteci i ne traže ništa instalirano osim onoga što Delphi izvršna datoteka ionako uključuje. Na dijeljenom mjestu s deset tisuća datoteka to je razlika između većeg dijela radnog dana i manje od minute, bez ijednog pitanja o postavljanju Officea

Kvaka s izravnim putovima jest to što ih je dva. Cjevovod koji prihvaća oba formata održava dva parsera s dvama nepovezanim načinima otkazivanja, kodne stranice i tipove PROPVARIANT s jedne strane, imenske prostore i neobvezne dijelove s druge, i nijedan ne čita format onog drugog. To opterećenje održavanjem argument je za izvornu biblioteku: HotXLS, losLabova Object Pascal biblioteka za proračunske tablice za Delphi i C++Builder na Windowsima, ista polja izlaže kao obična svojstva radne knjige, Title, Author, Company, Created i ostalo, koja poziv Open popunjava jednako za .xls i za .xlsx, bez instaliranog Excela i bez ijednog dijela gornjeg spremničkog vodovoda. Svojstva čita u sklopu potpunog otvaranja radne knjige, a ne kao sondu samo po metapodacima, pa odgovara cjevovodima koji ionako idu dalje u podatke ćelija; potpuna površina svojstava na obje fasade, uključujući stranu zapisivanja, obrađena je u našem članku o postavljanju svojstava Excel dokumenta pomoću HotXLS-a

Napomena: potpuni alati za parsiranje Excela i izvlačenje metapodataka dostupni su u komponenti HotXLS Delphi VCL Component