Tehnični članak

Branje lastnosti dokumenta Excel v Delphiju: tri poti

Naročite cevovodu, naj deset tisoč preglednic razvrsti po avtorju, podjetju ali datumu zadnje spremembe, in najslabše, kar lahko stori, je, da vsak delovni zvezek odpre v celoti. Odgovori tičijo v lastnostih dokumenta te datoteke, torej v tem, čemur svet Officea pravi Document Summary Information: v metapodatkovni plasti, ki jo indeksira Iskanje Windows, po kateri SharePoint razvršča in ki jo Excel prikaže v pogovornem oknu Lastnosti. Ta plast obsega kvečjemu nekaj kilobajtov in v obeh zapisih Excela živi na dobro dokumentiranem mestu. Trik je, kako do nje priti iz Delphija, ne da bi plačali za milijon celic, ki jih ne potrebujete

Prave poti so tri in razlikujejo se manj po tem, kaj vrnejo, kot po tem, kaj zahtevajo od stroja, ki jih poganja. Avtomatizacija COM poganja Excel sam in prebere vse, po namiznih cenah. Zapis .xls svoje lastnosti hrani v tokovih nabora lastnosti OLE, ki jih Windows razčleni namesto vas. Zapis .xlsx jih hrani v dveh majhnih delih XML znotraj datoteke zip, ki jo Delphijev RTL odpre sam. Sledi delujoča koda za vsako pot, s stroški, povedanimi naravnost

Prikaz treh poti v Delphiju do Document Summary Information v Excelu: avtomatizacija COM, ki poganja Excel sam, tokovi nabora lastnosti OLE za datoteke xls in razčlenjevanje XML docProps OOXML za pakete xlsx
Avtomatizacija COM kupi popolno pokritost za ceno licenciranega namiznega Excela in sekund na datoteko, medtem ko poti, izvorni za posamezni zapis, v milisekundah preberejo le vsebnike metapodatkov. Kar vsaka pot vrne, je skoraj isto — kar zahteva od gostiteljskega stroja, pa ne

Pot 1: avtomatizacija COM prebere vse, po namiznih cenah

Avtomatizacija je edina pot s popolno pokritostjo prek enega objektnega modela: standardni povzetkni nabor, razširjeni nabor s poljema Company in Manager ter uporabniško določene lastnosti po meri, vse dosegljivo prek BuiltinDocumentProperties in CustomDocumentProperties. Vse pride kot OleVariant, API pa ima eno navado, ki jo je vredno poznati, preden ugrizne: vgrajena lastnost, ki ni bila nikoli dodeljena, se ne vrne prazna, temveč sproži EOleException v trenutku, ko se dotaknete Value. Spodnji pomočnik to obravnava kot "ni nastavljeno" in ne kot napako

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 := '';   // lastnost obstaja, a ni bila nikoli dodeljena
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // samo za branje
    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;   // dosezite to po vsaki poti, sicer EXCEL.EXE ostane zadaj
    Excel := Unassigned;
  end;
end;

Zdaj pa račun. Excel mora biti nameščen na vsakem stroju, kjer ta koda teče, kar samo po sebi izloči večino strežnikov, Microsoftova podporna politika pa izrecno pravi, da Office ni ne zasnovan ne licenciran za nenadzorovano avtomatizacijo na strani strežnika. CreateOleObject zažene celoten EXCEL.EXE, Workbooks.Open pa razčleni celoten delovni zvezek, zato pričakujte približno dve do štiri sekunde na datoteko, preden se vrne prva lastnost. In try..finally okoli Quit ni okras: izjema, ki uide med CreateOleObject in Quit, pusti osirotel EXCEL.EXE, ki drži zaklep nad datoteko, neviden, dokler naslednji tek ob njem ne odpove. Ponovna uporaba enega primerka Excela čez cel paket začetni strošek porazdeli, a tveganje zgosti, saj eno zablodelo pogovorno okno na skritem namizju ustavi vsako datoteko, ki čaka za njim

Pot 2: .xls lastnosti hrani v tokovih nabora lastnosti OLE

Delovni zvezek BIFF8 je sestavljena datoteka OLE, miniaturen datotečni sistem shramb in tokov. Podatki celic živijo v toku Workbook; metapodatki živijo ob njem v dveh tokovih nabora lastnosti, katerih imeni se začneta s krmilnim znakom #5: \005SummaryInformation za klasična polja in \005DocumentSummaryInformation za razširjena in tista po meri. V vsakem sedi binarni nabor lastnosti v razporeditvi MS-OLEPS, z razdelki, ključenimi po identifikatorju zapisa (FMTID), in lastnostmi, ključenimi po celoštevilskem ID lastnosti. Povzetkni razdelek je FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, kjer je PIDSI_TITLE enak $02, PIDSI_AUTHOR pa $04; Company ($0F) in Manager ($0E) živita v razdelku povzetka dokumenta, lastnosti po meri pa v drugem razdelku za slovarjem imen

Anatomija sestavljene datoteke xls BIFF8 v Delphiju, kjer tok Workbook stoji ob naborih lastnosti SummaryInformation in DocumentSummaryInformation, z dostopno verigo od StgOpenStorageEx do IPropertySetStorage
Datoteka xls podatke celic in lastnosti dokumenta hrani kot sorodna tokova v sestavljeni datoteki OLE. Windows binarne nabore lastnosti razčleni namesto vas, zato se koda v Delphiju ne dotika ne razporeditev MS-OLEPS ne kodnih strani na roko

Dobra novica je, da v sistemu Windows teh bajtov nikoli ne razčlenjujete sami. Strukturirana shramba tokove razkrije prek IPropertySetStorage, spodnja koda pa se prevede tako, kot je prikazana, z običajnimi enotami RTL

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

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

Pošteno o tem, kaj izsek skriva. Nizi lahko pridejo kot VT_LPWSTR ali kot VT_LPSTR, v primeru ANSI pa so bajti kodirani v lastni kodni strani nabora lastnosti, ki je sama shranjena kot lastnost 1 razdelka, zato je zgornja pretvorba točna le, kadar se ta kodna stran ujema s sistemsko. Časovni žigi se vrnejo kot VT_FILETIME v UTC. Lastnosti po meri pomenijo odpiranje uporabniško določenega razdelka, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, in prehod njegovega slovarja imen. IPropertyStorage v sistemu Windows vse to prevzame nase; pisanje lastnega razčlenjevalnika MS-OLEPS za okolje brez strukturirane shrambe je pravi projekt in ne popoldanska naloga

Pot 3: .xlsx hrani docProps kot XML znotraj zip

To je pot, ki jo večina cevovodov v resnici potrebuje, saj so nove datoteke že skoraj dve desetletji .xlsx. Delovni zvezek OOXML je paket zip, njegove lastnosti pa so po namenu razdeljene med majhne dele: docProps/core.xml drži polja Dublin Core, dc:title, dc:creator, cp:lastModifiedBy ter dcterms:created in dcterms:modified kot časovna žiga W3CDTF v UTC, docProps/app.xml drži polja na ravni aplikacije, kot sta Company in AppVersion, docProps/custom.xml pa lastnosti po meri. Ker centralni imenik datoteke zip vsak del najde neposredno, njihovo branje stane nekaj kilobajtov ne glede na to, kako velik je delovni zvezek. TZipFile in IXMLDocument, oba priložena v RTL, opravita vse delo

Delphi: razporeditev paketa zip xlsx, kjer so člani docProps core, app in custom XML prikazani ob delih delovnih listov, skupaj s produkcijskimi pravili za preverjanje neobveznih delov in ujemanje imenskih prostorov
V paketu xlsx prevladujejo podatki delovnih listov, metapodatki pa sedijo ob njih v treh majhnih neobveznih članih. Naključni dostop prek centralnega imenika zip ohrani branje sorazmerno z lastnostmi in ne z delovnim zvezkom
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;

Dve podrobnosti to v produkciji ohranjata robustno. Prvič, deli so neobvezni: minimalen paket povsem brez docProps je po ECMA-376 popolnoma veljaven, in prav zato koda z IndexOf preveri, namesto da bi predpostavljala. Drugič, elemente ujemajte po lokalnem imenu in URI imenskega prostora, kot to zgoraj počne FindNode, nikoli po dobesedni predponi; dc: in cp: sta dogovora Excelovega pisalnika, datoteke iz drugih generatorjev pa smejo izbrati druge predpone. Ena okoljska opomba: privzeti ponudnik za IXMLDocument je MSXML, zato mora konzolna aplikacija ali delovna nit pred LoadXMLData poklicati CoInitialize, sicer prvo razčlenjevanje umre z napako COM

Stroškovnik in kdaj knjižnica premaga oba razčlenjevalnika

Izmerjeno na običajnem razvijalskem stroju pot COM pristane pri približno dveh do štirih sekundah na datoteko, kadar se seja avtomatizacije ustvari za vsako datoteko posebej, in skoraj vse to je zagon EXCEL.EXE plus polna razčlenitev delovnega zvezka, povrhu pa povsod, kjer teče, zahteva nameščen in licenciran Excel. Neposredni poti preberejo le vsebnike metapodatkov, končata v enomestnem številu milisekund na datoteko in ne potrebujeta ničesar nameščenega poleg tega, kar izvedljiva datoteka v Delphiju že vključuje. Na deljenem mestu z desetimi tisoč datotekami je to razlika med večjim delom delovnega dne in manj kot minuto, brez vsakršnega vprašanja o nameščanju Officea

Zadrega pri neposrednih poteh je, da sta dve. Cevovod, ki sprejema oba zapisa, vzdržuje dva razčlenjevalnika z dvema nepovezanima načinoma odpovedi, na eni strani kodne strani in tipi PROPVARIANT, na drugi imenski prostori in neobvezni deli, in nobeden ne bere zapisa drugega. Prav to vzdrževalno breme je argument za izvorno knjižnico: HotXLS, knjižnica losLab za preglednice v Object Pascalu za Delphi in C++Builder v sistemu Windows, ista polja razkriva kot navadne lastnosti delovnega zvezka, Title, Author, Company, Created in preostale, ki jih Open napolni enako za .xls in .xlsx, brez nameščenega Excela in brez vse zgornje vodovodne napeljave okoli vsebnikov. Lastnosti bere kot del polnega odpiranja delovnega zvezka in ne kot sondo samo za metapodatke, zato se prilega cevovodom, ki se tako ali tako lotijo tudi podatkov celic; celotna površina lastnosti na obeh fasadah, vključno s pisalno stranjo, je obravnavana v našem članku o nastavljanju lastnosti dokumenta Excel s HotXLS

Opomba: celovita orodja za razčlenjevanje Excela in izvleček metapodatkov so na voljo v komponenti HotXLS Delphi VCL