Tehnički članak

Izdvajanje informacija o sažetku dokumenta iz Excel datoteka u Delphiju

Ako cevovod treba da usmeri deset hiljada tabela prema autoru, kompaniji ili datumu poslednje izmene, najskuplja moguća odluka je potpuno otvaranje svake radne sveske. Odgovori se nalaze u svojstvima dokumenta, sloju koji Office naziva Document Summary Information: Windows Search ga indeksira, SharePoint prema njemu razvrstava datoteke, a Excel ga prikazuje u dijalogu Properties. Taj sloj zauzima najviše nekoliko kilobajta i nalazi se na dobro dokumentovanom mestu u oba Excel formata. Pravi zadatak je pristupiti mu iz Delphija bez plaćanja troška za milion ćelija koje vam nisu potrebne

Postoje tri stvarne putanje, koje se manje razlikuju po rezultatima nego po zahtevima prema računaru. COM automatizacija pokreće sam Excel i čita sve, uz cenu desktop aplikacije. Format .xls čuva svojstva u OLE tokovima skupova svojstava koje Windows može da obradi. Format .xlsx čuva ih u dva mala XML dela unutar zip arhive koju Delphi RTL može sam da otvori. U nastavku je radni kod za svaku putanju, zajedno sa jasnim troškovima

Putanja 1: COM automatizacija čita sve po desktop ceni

Automatizacija je jedina putanja koja kroz jedan objektni model daje potpunu pokrivenost: standardni skup sažetka, prošireni skup sa Company i Manager, kao i korisnički definisana prilagođena svojstva, dostupna preko BuiltinDocumentProperties i CustomDocumentProperties. Sve stiže kao OleVariant, a API ima jednu važnu naviku: ugrađeno svojstvo kojem vrednost nikada nije dodeljena ne vraća prazan rezultat, već izaziva EOleException čim pristupite svojstvu Value. Pomoćna funkcija u nastavku to tretira kao „nije postavljeno“, a ne kao neuspeh

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 := '';   // property exists but was never assigned
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // read-only
    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;   // reach this on every path, or EXCEL.EXE stays behind
    Excel := Unassigned;
  end;
end;

Sada cena. Excel mora biti instaliran na svakom računaru na kojem se kod izvršava, što samo po sebi isključuje većinu servera, a Microsoft jasno navodi da Office nije projektovan niti licenciran za automatizaciju bez korisnika na serveru. CreateOleObject pokreće ceo EXCEL.EXE, a Workbooks.Open parsira kompletnu radnu svesku, pa očekujte približno dve do četiri sekunde po datoteci pre povratka prvog svojstva. Blok try..finally oko Quit nije ukras: izuzetak između CreateOleObject i Quit ostavlja napušteni EXCEL.EXE koji drži zaključavanje datoteke sve dok sledeće pokretanje ne padne. Ponovna upotreba jedne Excel instance kroz grupu smanjuje cenu pokretanja, ali sabira rizik, jer jedan skriveni dijalog zaustavlja sve datoteke iza njega

Putanja 2: .xls čuva svojstva u OLE tokovima

BIFF8 radna sveska je OLE složena datoteka, mali sistem skladišta i tokova. Podaci ćelija nalaze se u toku Workbook, a metapodaci pored njega u dva toka skupa svojstava čija imena počinju kontrolnim znakom #5: \005SummaryInformation za klasična polja i \005DocumentSummaryInformation za proširena i prilagođena. Svaki sadrži binarni skup svojstava u rasporedu MS-OLEPS, sa sekcijama označenim identifikatorom formata FMTID i svojstvima označenim celobrojnim ID-jem. Sekcija sažetka koristi FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, gde je PIDSI_TITLE $02, a PIDSI_AUTHOR $04; Company ($0F) i Manager ($0E) nalaze se u sekciji sažetka dokumenta, dok su prilagođena svojstva u drugoj sekciji iza rečnika imena

Dobra vest je da na Windows-u ne morate sami parsirati te bajtove. Structured storage izlaže tokove preko IPropertySetStorage, a sledeći kod se prevodi kako je prikazan koristeći 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: not present
  try
    case Value.vt of
      VT_LPSTR:  Result := string(AnsiString(Value.pszVal));
      VT_LPWSTR: Result := Value.pwszVal;
    end;
  finally
    PropVariantClear(Value);
  end;
end;

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

Primer namerno ne skriva ograničenja. Stringovi mogu stići kao VT_LPWSTR ili VT_LPSTR; u ANSI slučaju bajtovi koriste kodnu stranu samog skupa svojstava, koja je zapisana kao svojstvo 1 sekcije, pa je prikazani cast tačan samo kada se ta kodna strana poklapa sa sistemskom. Vremenske oznake stižu kao VT_FILETIME u UTC-u. Prilagođena svojstva zahtevaju otvaranje korisnički definisane sekcije, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, i obilazak njenog rečnika imena. IPropertyStorage na Windows-u preuzima taj posao; pisanje sopstvenog MS-OLEPS parsera za okruženje bez structured storage-a ozbiljan je projekat, a ne popodnevni zadatak

Putanja 3: .xlsx čuva docProps kao XML u zip arhivi

To je putanja potrebna većini cevovoda, pošto su nove datoteke gotovo dve decenije uglavnom .xlsx. OOXML radna sveska je zip paket, a svojstva su podeljena u male delove prema nameni: docProps/core.xml sadrži Dublin Core polja dc:title, dc:creator i cp:lastModifiedBy, kao i dcterms:created i dcterms:modified kao W3CDTF vremenske oznake u UTC-u; docProps/app.xml sadrži aplikativna polja kao što su Company i AppVersion, dok docProps/custom.xml sadrži prilagođena svojstva. Pošto centralni direktorijum zip-a direktno pronalazi svaki deo, čita se nekoliko kilobajta bez obzira na veličinu radne sveske. TZipFile i IXMLDocument, oba deo isporučenog RTL-a, obavljaju ceo posao

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 čine ovo rešenje pouzdanim u produkciji. Prvo, delovi su opcioni: minimalan paket bez ijednog docProps dela potpuno je važeći prema ECMA-376, zato kod proverava pomoću IndexOf umesto da pretpostavlja postojanje. Drugo, elemente uparujte prema lokalnom imenu i URI-ju prostora imena, kao u funkciji FindNode, a nikada prema doslovnom prefiksu; dc: i cp: su konvencije Excel pisača, dok drugi generatori mogu izabrati druge prefikse. Još jedna napomena o okruženju: podrazumevani dobavljač za IXMLDocument je MSXML, pa konzolna aplikacija ili radna nit moraju pozvati CoInitialize pre LoadXMLData, inače prvo parsiranje pada sa COM greškom

Troškovi i trenutak kada biblioteka pobeđuje oba parsera

Na uobičajenom razvojnom računaru COM putanja traje približno dve do četiri sekunde po datoteci kada se sesija automatizacije pravi za svaku datoteku; gotovo sve vreme odlazi na pokretanje EXCEL.EXE i potpuno parsiranje radne sveske, uz obavezan instaliran i licenciran Excel. Dve direktne putanje čitaju samo kontejnere metapodataka, završavaju za jednocifren broj milisekundi po datoteci i ne zahtevaju ništa izvan onoga što Delphi izvršna datoteka već povezuje. Na deljenom prostoru sa deset hiljada datoteka to je razlika između većeg dela radnog dana i manje od minuta, bez pitanja o instalaciji Office-a

Problem direktnih putanja je što ih ima dve. Cevovod koji prihvata oba formata održava dva parsera sa odvojenim načinima kvara: kodne strane i tipove PROPVARIANT na jednoj strani, prostore imena i opcione delove na drugoj, pri čemu nijedan ne čita format onog drugog. Taj trošak održavanja opravdava izvornu biblioteku: HotXLS, losLab Object Pascal biblioteka za tabele u Delphi i C++Builder aplikacijama na Windows-u, izlaže ista polja kao obična svojstva radne sveske, Title, Author, Company, Created i ostala, popunjena pozivom Open za .xls i .xlsx bez instaliranog Excela i bez rada sa kontejnerima iznad. Svojstva čita kao deo potpunog otvaranja radne sveske, a ne kao probu samo metapodataka, pa odgovara cevovodima koji zatim obrađuju i ćelije; ceo skup svojstava na oba interfejsa, uključujući upis, opisan je u tekstu o podešavanju svojstava Excel dokumenata pomoću HotXLS-a

Napomena: Potpuno parsiranje Excela i alati za izdvajanje metapodataka dostupni su u HotXLS VCL Component