Articolo tecnico

Leggere le proprietà dei documenti Excel in Delphi: tre vie

Chiedete a una pipeline di smistare diecimila fogli di calcolo per autore, azienda o data di ultima modifica, e la cosa peggiore che possa fare è aprire per intero ogni cartella di lavoro. Le risposte viaggiano nelle proprietà del documento, ciò che nel mondo Office si chiama Document Summary Information: lo strato di metadati che Windows Search indicizza, su cui SharePoint archivia e che Excel mostra nella finestra Proprietà. Quello strato occupa al massimo qualche kilobyte, e in entrambi i formati Excel risiede in un punto ben documentato. Il trucco sta nel raggiungerlo da Delphi senza pagare per il milione di celle che non vi serve

Le vie reali sono tre, e differiscono meno per ciò che restituiscono che per ciò che pretendono dalla macchina che le esegue. L'automazione COM pilota Excel stesso e legge tutto, a prezzi da desktop. Il formato .xls conserva le proprie proprietà in flussi OLE property-set che Windows analizza al posto vostro. Il formato .xlsx le conserva in due piccole parti XML dentro uno zip che la RTL di Delphi sa aprire da sola. Segue codice funzionante per ciascuna, con i costi dichiarati apertamente

Schema delle tre vie Delphi verso le Document Summary Information di Excel: automazione COM che pilota Excel stesso, flussi OLE property-set per i file xls e parsing XML di docProps OOXML per i pacchetti xlsx
L'automazione COM compra una copertura totale al prezzo di un Excel desktop con licenza e di alcuni secondi per file, mentre le due vie native al formato leggono solo i contenitori di metadati in millisecondi. Ciò che ciascuna via restituisce è quasi lo stesso — ciò che pretende dalla macchina ospite no

Via 1: l'automazione COM legge tutto, a prezzi da desktop

L'automazione è l'unica via con copertura totale attraverso un solo modello a oggetti: l'insieme di riepilogo standard, l'insieme esteso con Company e Manager e le proprietà personalizzate definite dall'utente, tutte raggiungibili tramite BuiltinDocumentProperties e CustomDocumentProperties. Tutto arriva come OleVariant, e l'API ha un'abitudine che conviene conoscere prima che morda: una proprietà predefinita mai assegnata non torna vuota, solleva una EOleException nell'istante in cui toccate Value. L'helper qui sotto la tratta come "non impostata" anziché come un guasto

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 := '';   // la proprietà esiste ma non è mai stata assegnata
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // sola lettura
    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;   // da raggiungere su ogni percorso, altrimenti EXCEL.EXE resta attivo
    Excel := Unassigned;
  end;
end;

Ora il conto. Excel deve essere installato su ogni macchina su cui questo codice gira, il che da solo esclude la maggior parte dei server, e la policy di supporto di Microsoft è esplicita nel dire che Office non è né progettato né licenziato per l'automazione lato server senza presidio. CreateOleObject avvia un EXCEL.EXE completo e Workbooks.Open analizza l'intera cartella di lavoro, quindi aspettatevi all'incirca da due a quattro secondi per file prima che torni la prima proprietà. E il try..finally attorno a Quit non è decorazione: un'eccezione che sfugge tra CreateOleObject e Quit lascia un EXCEL.EXE orfano che tiene un lock sul file, invisibile finché l'esecuzione successiva non fallisce proprio su quello. Riutilizzare una sola istanza di Excel per tutto un lotto ammortizza il costo di avvio ma concentra il rischio, perché una sola finestra di dialogo vagante sul desktop nascosto blocca ogni file in coda dietro di essa

Via 2: .xls conserva le proprietà in flussi OLE property-set

Una cartella di lavoro BIFF8 è un file composto OLE, un file system in miniatura fatto di storage e stream. I dati delle celle vivono nello stream Workbook; i metadati vivono accanto in due flussi property-set i cui nomi iniziano con il carattere di controllo #5: \005SummaryInformation per i campi classici e \005DocumentSummaryInformation per quelli estesi e personalizzati. Dentro ciascuno si trova un insieme binario di proprietà nel layout MS-OLEPS, con sezioni identificate da un identificatore di formato (FMTID) e proprietà identificate da un ID intero. La sezione di riepilogo è l'FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, dove PIDSI_TITLE è $02 e PIDSI_AUTHOR è $04; Company ($0F) e Manager ($0E) vivono nella sezione di riepilogo del documento, e le proprietà personalizzate in una seconda sezione dietro un dizionario di nomi

Anatomia in Delphi di un file composto xls BIFF8 che colloca lo stream Workbook accanto ai property set SummaryInformation e DocumentSummaryInformation, con la catena di accesso da StgOpenStorageEx a IPropertySetStorage
Un file xls conserva i dati delle celle e le proprietà del documento come stream fratelli in un file composto OLE. Windows analizza per voi gli insiemi binari di proprietà, così il codice Delphi non tocca a mano né i layout MS-OLEPS né le code page

La buona notizia è che su Windows quei byte non li analizzate mai voi. Lo structured storage espone i flussi tramite IPropertySetStorage, e quanto segue compila così come è scritto con le unit RTL standard

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

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

Una parola onesta su ciò che lo snippet nasconde. Le stringhe possono arrivare come VT_LPWSTR o come VT_LPSTR, e nel caso ANSI i byte sono codificati nella code page dell'insieme di proprietà stesso, a sua volta memorizzata come proprietà 1 della sezione, quindi il cast qui sopra è esatto solo quando quella code page coincide con quella di sistema. Le marche temporali tornano come VT_FILETIME in UTC. Le proprietà personalizzate implicano aprire la sezione definita dall'utente, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, e percorrerne il dizionario dei nomi. IPropertyStorage assorbe tutto questo su Windows; scrivere un vostro parser MS-OLEPS per un ambiente privo di structured storage è un progetto vero e proprio, non un pomeriggio di lavoro

Via 3: .xlsx tiene docProps come XML dentro lo zip

Questa è la via di cui la maggior parte delle pipeline ha davvero bisogno, dato che i file nuovi sono .xlsx da quasi vent'anni. Una cartella di lavoro OOXML è un pacchetto zip, e le sue proprietà sono suddivise in piccole parti per scopo: docProps/core.xml contiene i campi Dublin Core, dc:title, dc:creator, cp:lastModifiedBy, più dcterms:created e dcterms:modified come marche temporali W3CDTF in UTC, mentre docProps/app.xml contiene i campi a livello di applicazione come Company e AppVersion, e docProps/custom.xml contiene le proprietà personalizzate. Poiché la directory centrale dello zip individua direttamente ciascuna parte, leggerle costa qualche kilobyte per quanto grande sia la cartella di lavoro. TZipFile e IXMLDocument, entrambi nella RTL distribuita, svolgono l'intero lavoro

Delphi: struttura di un pacchetto zip xlsx che mostra i membri XML docProps core, app e custom accanto alle parti dei fogli di lavoro, con le regole pratiche per sondare le parti opzionali e far corrispondere i namespace
I dati dei fogli dominano un pacchetto xlsx, eppure i metadati stanno accanto in tre piccoli membri opzionali. L'accesso casuale tramite la directory centrale dello zip mantiene la lettura proporzionale alle proprietà, non alla cartella di lavoro
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;

Due dettagli mantengono tutto questo robusto in produzione. Primo, le parti sono opzionali: un pacchetto minimo del tutto privo di docProps è perfettamente valido secondo ECMA-376, ed è per questo che il codice sonda con IndexOf anziché dare per scontato. Secondo, fate corrispondere gli elementi per nome locale e URI di namespace, come fa FindNode qui sopra, mai per prefisso letterale; dc: e cp: sono convenzioni dello scrittore di Excel, e i file prodotti da altri generatori sono liberi di scegliere prefissi diversi. Una nota di ambiente: il vendor predefinito di IXMLDocument è MSXML, quindi un'applicazione console o un thread di lavoro devono chiamare CoInitialize prima di LoadXMLData, altrimenti il primo parsing muore con un errore COM

Il prospetto dei costi, e quando una libreria batte entrambi i parser

Misurata su una normale macchina da sviluppo, la via COM si assesta attorno ai due-quattro secondi per file quando la sessione di automazione viene creata per ogni file, quasi tutti dovuti all'avvio di EXCEL.EXE più l'analisi completa della cartella di lavoro, e richiede un Excel installato e licenziato ovunque giri. Le due vie dirette leggono solo i contenitori di metadati, si concludono in millisecondi a una cifra per file e non hanno bisogno di nulla di installato oltre a ciò che un eseguibile Delphi collega già al suo interno. Su una condivisione da diecimila file, questa è la differenza tra buona parte di una giornata di lavoro e meno di un minuto, senza alcuna questione di deployment di Office attaccata

Il rovescio delle vie dirette è che sono due. Una pipeline che accetta entrambi i formati mantiene due parser con due modalità di guasto disgiunte, code page e tipi PROPVARIANT da un lato, namespace e parti opzionali dall'altro, e nessuno dei due legge il formato dell'altro. Quel carico di manutenzione è l'argomento a favore di una libreria nativa: HotXLS, la libreria per fogli di calcolo in Object Pascal di losLab per Delphi e C++Builder su Windows, espone gli stessi campi come semplici proprietà della cartella di lavoro, Title, Author, Company, Created e gli altri, popolate da Open tanto per .xls quanto per .xlsx, senza installazione di Excel e senza nessuna delle idraulicherie di contenitore viste sopra. Legge le proprietà come parte di un'apertura completa della cartella di lavoro anziché come sondaggio dei soli metadati, quindi si adatta alle pipeline che comunque andranno a toccare i dati delle celle; l'intera superficie di proprietà su entrambe le facciate, lato scrittura incluso, è trattata nel nostro articolo su come impostare le proprietà dei documenti Excel con HotXLS

Nota: gli strumenti completi di analisi di Excel e di estrazione dei metadati sono disponibili nel componente VCL Delphi HotXLS