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
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
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
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