Articol tehnic

Proprietăți de document Excel în Delphi: trei rute

Cereți unui flux de procesare să direcționeze zece mii de foi de calcul după autor, companie sau data ultimei modificări, iar cel mai rău lucru pe care îl poate face este să deschidă complet fiecare registru de lucru. Răspunsurile călătoresc în proprietățile de document ale fișierului, ceea ce lumea Office numește Document Summary Information: stratul de metadate pe care Windows Search îl indexează, după care SharePoint clasifică fișierele și pe care Excel îl arată în dialogul său Properties. Acel strat are cel mult câțiva kiloocteți și stă într-un loc bine documentat în ambele formate Excel. Șmecheria este să ajungeți la el din Delphi fără să plătiți pentru milionul de celule de care nu aveți nevoie

Există trei rute reale, iar ele diferă mai puțin prin ceea ce returnează decât prin ceea ce pretind de la mașina care le rulează. Automatizarea COM pilotează Excel însuși și citește totul, la prețuri de desktop. Formatul .xls își păstrează proprietățile în fluxuri OLE de tip property-set pe care Windows le analizează în locul dumneavoastră. Formatul .xlsx le păstrează în două părți XML mici din interiorul unei arhive zip pe care RTL-ul Delphi o poate deschide singur. Urmează cod funcțional pentru fiecare, cu costurile spuse pe față

Schemă a celor trei rute Delphi către Document Summary Information din Excel: automatizare COM care pilotează Excel însuși, fluxuri OLE property-set pentru fișierele xls și analiza XML docProps pentru pachetele xlsx
Automatizarea COM cumpără acoperire totală cu prețul unui Excel desktop licențiat și al câtorva secunde pe fișier, în timp ce cele două rute native formatului citesc doar containerele de metadate, în milisecunde. Ce returnează fiecare rută este aproape același lucru — ce pretinde de la mașina gazdă, nu

Ruta 1: automatizarea COM citește tot, la prețuri de desktop

Automatizarea este singura rută cu acoperire totală printr-un singur model de obiecte: setul standard de sumar, setul extins cu Company și Manager și proprietățile personalizate definite de utilizator, toate accesibile prin BuiltinDocumentProperties și CustomDocumentProperties. Totul sosește ca OleVariant, iar API-ul are un obicei pe care merită să îl știți înainte să vă muște: o proprietate încorporată care nu a fost niciodată atribuită nu se întoarce goală, ci ridică o EOleException în clipa în care atingeți Value. Funcția ajutătoare de mai jos tratează asta drept „nesetat”, nu drept eșec

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 := '';   // proprietatea există, dar nu a fost niciodată atribuită
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // doar citire
    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;   // ajungeți aici pe orice cale, altfel EXCEL.EXE rămâne în urmă
    Excel := Unassigned;
  end;
end;

Acum și nota de plată. Excel trebuie instalat pe fiecare mașină pe care rulează acest cod, ceea ce exclude prin sine majoritatea serverelor, iar politica de suport Microsoft spune explicit că Office nu este nici proiectat, nici licențiat pentru automatizare nesupravegheată pe partea de server. CreateOleObject lansează un EXCEL.EXE complet, iar Workbooks.Open analizează întregul registru de lucru, deci așteptați-vă la aproximativ două până la patru secunde pe fișier până când se întoarce prima proprietate. Iar try..finally din jurul lui Quit nu este decorativ: o excepție care scapă între CreateOleObject și Quit lasă în urmă un EXCEL.EXE orfan care ține fișierul blocat, invizibil până când următoarea rulare eșuează din cauza lui. Reutilizarea unei singure instanțe Excel pe tot lotul amortizează costul de pornire, dar concentrează riscul, pentru că un singur dialog rătăcit pe desktopul ascuns blochează fiecare fișier din coada de după el

Ruta 2: .xls stochează proprietățile în fluxuri OLE property-set

Un registru de lucru BIFF8 este un fișier compus OLE, un sistem de fișiere în miniatură, cu depozite și fluxuri. Datele celulelor stau în fluxul Workbook; metadatele stau alături, în două fluxuri de tip property-set ale căror nume încep cu caracterul de control #5: \005SummaryInformation pentru câmpurile clasice și \005DocumentSummaryInformation pentru cele extinse și personalizate. În fiecare stă un set binar de proprietăți în aranjamentul MS-OLEPS, cu secțiuni cheiate după un identificator de format (FMTID) și proprietăți cheiate după un ID întreg de proprietate. Secțiunea de sumar are FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, unde PIDSI_TITLE este $02, iar PIDSI_AUTHOR este $04; Company ($0F) și Manager ($0E) stau în secțiunea de sumar al documentului, iar proprietățile personalizate într-o a doua secțiune, în spatele unui dicționar de nume

Anatomie Delphi a unui fișier compus xls BIFF8, care așază fluxul Workbook lângă seturile de proprietăți SummaryInformation și DocumentSummaryInformation, cu lanțul de acces de la StgOpenStorageEx la IPropertySetStorage
Un fișier xls stochează datele celulelor și proprietățile documentului ca fluxuri surori într-un fișier compus OLE. Windows analizează seturile binare de proprietăți în locul dumneavoastră, așa că nici codul Delphi nu atinge de mână aranjamente MS-OLEPS sau pagini de coduri

Vestea bună este că pe Windows nu analizați niciodată acei octeți chiar dumneavoastră. Stocarea structurată expune fluxurile prin IPropertySetStorage, iar codul următor compilează așa cum este scris, cu unitățile RTL din livrare

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

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

O vorbă cinstită despre ce ascunde fragmentul. Șirurile pot sosi ca VT_LPWSTR sau ca VT_LPSTR, iar în cazul ANSI octeții sunt codificați în pagina de coduri proprie a setului de proprietăți, stocată ea însăși ca proprietatea 1 a secțiunii, așa că turnarea de mai sus este exactă doar când acea pagină de coduri coincide cu cea a sistemului. Marcajele de timp se întorc ca VT_FILETIME în UTC. Proprietățile personalizate înseamnă deschiderea secțiunii definite de utilizator, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, și parcurgerea dicționarului ei de nume. IPropertyStorage absoarbe tot acest efort pe Windows; scrierea propriului analizor MS-OLEPS pentru un mediu fără stocare structurată este un proiect adevărat, nu o după-amiază de lucru

Ruta 3: .xlsx păstrează docProps ca XML în interiorul arhivei zip

Aceasta este ruta de care au nevoie de fapt majoritatea fluxurilor, de vreme ce fișierele noi sunt .xlsx de aproape două decenii. Un registru de lucru OOXML este un pachet zip, iar proprietățile lui sunt împărțite pe părți mici, după scop: docProps/core.xml conține câmpurile Dublin Core, dc:title, dc:creator, cp:lastModifiedBy, plus dcterms:created și dcterms:modified ca marcaje de timp W3CDTF în UTC, în timp ce docProps/app.xml conține câmpuri la nivel de aplicație, precum Company și AppVersion, iar docProps/custom.xml conține proprietățile personalizate. Fiindcă directorul central al arhivei zip localizează direct fiecare parte, citirea lor costă câțiva kiloocteți, oricât de mare ar fi registrul de lucru. TZipFile și IXMLDocument, ambele din RTL-ul livrat, fac toată treaba

Delphi: aranjamentul unui pachet zip xlsx, care arată membrii XML docProps core, app și custom alături de părțile de foaie de calcul, cu regulile de producție pentru sondarea părților opționale și potrivirea spațiilor de nume
Datele foilor de calcul domină un pachet xlsx, însă metadatele stau alături, în trei membri mici și opționali. Accesul aleatoriu prin directorul central al arhivei zip menține citirea proporțională cu proprietățile, nu cu registrul de lucru
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;

Două detalii țin acest cod robust în producție. Întâi, părțile sunt opționale: un pachet minimal, fără niciun docProps, este perfect valid conform ECMA-376, motiv pentru care codul sondează cu IndexOf în loc să presupună. Apoi, potriviți elementele după numele local și URI-ul spațiului de nume, așa cum face FindNode mai sus, niciodată după prefixul literal; dc: și cp: sunt convenții ale scriitorului Excel, iar fișierele produse de alte generatoare au voie să aleagă alte prefixe. O notă de mediu: furnizorul implicit pentru IXMLDocument este MSXML, deci o aplicație de consolă sau un fir de execuție lucrător trebuie să apeleze CoInitialize înainte de LoadXMLData, altfel prima analiză moare cu o eroare COM

Fișa de costuri și momentul în care o bibliotecă bate ambele analizoare

Măsurată pe o mașină obișnuită de dezvoltare, ruta COM ajunge la aproximativ două până la patru secunde pe fișier atunci când sesiunea de automatizare este creată pentru fiecare fișier, aproape totul fiind pornirea EXCEL.EXE plus analiza completă a registrului de lucru, și cere un Excel instalat și licențiat oriunde rulează. Cele două rute directe citesc doar containerele de metadate, se termină în milisecunde exprimate cu o singură cifră pe fișier și nu au nevoie de nimic instalat dincolo de ce leagă deja un executabil Delphi. Pe o partajare cu zece mii de fișiere, aceasta este diferența dintre mai toată o zi de lucru și sub un minut, fără nicio întrebare de implementare Office atașată

Problema cu rutele directe este că sunt două. Un flux care acceptă ambele formate întreține două analizoare cu două moduri de eșec disjuncte, pagini de coduri și tipuri PROPVARIANT pe o parte, spații de nume și părți opționale pe cealaltă, iar niciunul nu citește formatul celuilalt. Acea povară de întreținere este argumentul pentru o bibliotecă nativă: HotXLS, biblioteca Object Pascal de foi de calcul de la losLab pentru Delphi și C++Builder pe Windows, expune aceleași câmpuri ca simple proprietăți de registru de lucru, Title, Author, Company, Created și restul, populate de Open deopotrivă pentru .xls și .xlsx, fără instalare de Excel și fără nimic din instalația de containere de mai sus. Ea citește proprietățile ca parte a deschiderii complete a registrului de lucru, nu ca pe o sondare doar de metadate, așa că se potrivește fluxurilor care oricum ajung să atingă datele celulelor; suprafața completă de proprietăți pe ambele fațade, inclusiv partea de scriere, este tratată în articolul nostru despre setarea proprietăților de document Excel cu HotXLS

Notă: instrumentele complete de analiză Excel și de extragere a metadatelor sunt disponibile în HotXLS Delphi VCL Component