Pida a una canalización que enrute diez mil hojas de cálculo por autor, empresa o fecha de última modificación, y lo peor que puede hacer es abrir cada libro por completo. Las respuestas viajan en las propiedades de documento del archivo, lo que en el mundo de Office se conoce como Document Summary Information: la capa de metadatos que indexa Windows Search, con la que clasifica los archivos SharePoint y que Excel muestra en su cuadro de diálogo de propiedades. Esa capa ocupa como mucho unos kilobytes y reside en un lugar bien documentado en ambos formatos de Excel. El truco está en llegar a ella desde Delphi sin pagar el coste del millón de celdas que no necesita
Existen tres rutas reales, y difieren menos en lo que devuelven que en lo que exigen a la máquina que las ejecuta. La automatización COM controla el propio Excel y lo lee todo, a precio de escritorio. El formato .xls guarda sus propiedades en flujos de conjuntos de propiedades OLE que Windows analiza por usted. El formato .xlsx las guarda en dos pequeñas partes XML dentro de un zip que el RTL de Delphi puede abrir por sí solo. A continuación se muestra código funcional para cada una, con los costes indicados sin rodeos
Ruta 1: la automatización COM lo lee todo, a precio de escritorio
La automatización es la única ruta con cobertura total a través de un solo modelo de objetos: el conjunto de resumen estándar, el conjunto extendido con Empresa y Gerente, y las propiedades personalizadas definidas por el usuario, todas accesibles mediante BuiltinDocumentProperties y CustomDocumentProperties. Todo llega como un OleVariant, y la API tiene una costumbre que conviene conocer antes de que le muerda: una propiedad integrada que nunca se asignó no vuelve vacía, sino que lanza una EOleException en el momento en que se toca Value. La función auxiliar de abajo trata eso como "no establecida" en lugar de como un fallo
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 propiedad existe pero nunca se asignó
end;
end;
begin
Excel := CreateOleObject('Excel.Application');
try
Excel.DisplayAlerts := False;
Book := Excel.Workbooks.Open(FileName, 0, True); // solo lectura
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; // llegue aquí en todos los caminos, o EXCEL.EXE quedará abierto en segundo plano
Excel := Unassigned;
end;
end;
Ahora la factura. Excel debe estar instalado en cada máquina donde se ejecute este código, lo que descarta la mayoría de los servidores por sí solo, y la política de soporte de Microsoft es explícita en que Office no está diseñado ni licenciado para la automatización desatendida en el lado del servidor. CreateOleObject lanza un EXCEL.EXE completo y Workbooks.Open analiza todo el libro, así que hay que contar con dos a cuatro segundos por archivo antes de que llegue la primera propiedad. Y el try..finally alrededor de Quit no es decoración: una excepción que escapa entre CreateOleObject y Quit deja un EXCEL.EXE huérfano reteniendo un bloqueo sobre el archivo, invisible hasta que la siguiente ejecución falle contra él. Reutilizar una única instancia de Excel a lo largo de un lote amortiza el coste de arranque, pero concentra el riesgo, porque un solo cuadro de diálogo perdido en el escritorio oculto detiene todos los archivos en cola detrás de él
Ruta 2: .xls almacena las propiedades en flujos de conjuntos de propiedades OLE
Un libro BIFF8 es un archivo compuesto OLE, un sistema de archivos en miniatura formado por almacenamientos y flujos. Los datos de las celdas residen en el flujo Workbook; los metadatos residen junto a ellos en dos flujos de conjuntos de propiedades cuyos nombres empiezan con el carácter de control n.º 5: \005SummaryInformation para los campos clásicos y \005DocumentSummaryInformation para los extendidos y personalizados. Dentro de cada uno hay un conjunto de propiedades binario con el formato MS-OLEPS, con secciones identificadas por un identificador de formato (FMTID) y propiedades identificadas por un ID de propiedad entero. La sección de resumen es el FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, donde PIDSI_TITLE es $02 y PIDSI_AUTHOR es $04; Empresa ($0F) y Gerente ($0E) residen en la sección de resumen de documento extendido, y las propiedades personalizadas en una segunda sección detrás de un diccionario de nombres
La buena noticia es que en Windows nunca hace falta analizar esos bytes uno mismo. El almacenamiento estructurado expone los flujos a través de IPropertySetStorage, y el siguiente código compila tal cual contra las unidades estándar del 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: no está 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 palabra honesta sobre lo que el fragmento oculta. Las cadenas pueden llegar como VT_LPWSTR o como VT_LPSTR, y en el caso ANSI los bytes están codificados en la página de códigos propia del conjunto de propiedades, almacenada a su vez como la propiedad 1 de la sección, de modo que el cast anterior solo es exacto cuando esa página de códigos coincide con la del sistema. Las marcas de tiempo vuelven como VT_FILETIME en UTC. Las propiedades personalizadas implican abrir la sección definida por el usuario, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, y recorrer su diccionario de nombres. IPropertyStorage se encarga de todo eso en Windows; escribir un analizador MS-OLEPS propio para un entorno sin almacenamiento estructurado es un proyecto de verdad, no cosa de una tarde
Ruta 3: .xlsx guarda docProps como XML dentro del zip
Esta es la ruta que la mayoría de las canalizaciones realmente necesitan, ya que los archivos nuevos llevan casi dos décadas siendo .xlsx. Un libro OOXML es un paquete zip, y sus propiedades se reparten en pequeñas partes según su finalidad: docProps/core.xml contiene los campos Dublin Core, dc:title, dc:creator, cp:lastModifiedBy, además de dcterms:created y dcterms:modified como marcas de tiempo W3CDTF en UTC, mientras que docProps/app.xml contiene campos de nivel de aplicación como Company y AppVersion, y docProps/custom.xml contiene las propiedades personalizadas. Como el directorio central del zip localiza cada parte directamente, leerlas cuesta apenas unos kilobytes sin importar lo grande que sea el libro. TZipFile e IXMLDocument, ambos incluidos en el RTL, hacen todo el trabajo
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;
Dos detalles mantienen esto robusto en producción. Primero, las partes son opcionales: un paquete mínimo sin ningún docProps es perfectamente válido según ECMA-376, por lo que el código sondea con IndexOf en lugar de asumir su presencia. Segundo, haga coincidir los elementos por nombre local y URI de espacio de nombres, tal como hace FindNode arriba, nunca por prefijo literal; dc: y cp: son convenciones del escritor de Excel, y los archivos producidos por otros generadores son libres de elegir prefijos distintos. Una nota de entorno: el proveedor predeterminado de IXMLDocument es MSXML, por lo que una aplicación de consola o un hilo de trabajo debe llamar a CoInitialize antes de LoadXMLData, o el primer análisis morirá con un error COM
La hoja de costes, y cuándo una librería supera a los dos analizadores
Medida en una máquina de desarrollo normal, la ruta COM se sitúa en torno a dos a cuatro segundos por archivo cuando la sesión de automatización se crea por archivo, casi todo ello arranque de EXCEL.EXE más un análisis completo del libro, y requiere un Excel instalado y con licencia allí donde se ejecute. Las dos rutas directas leen solo los contenedores de metadatos, terminan en milisegundos de un solo dígito por archivo, y no necesitan nada instalado más allá de lo que un ejecutable de Delphi ya enlaza. En una carpeta compartida con diez mil archivos, esa es la diferencia entre casi toda una jornada laboral y menos de un minuto, sin ninguna cuestión de implementación de Office de por medio
El inconveniente de las rutas directas es que son dos. Una canalización que acepta ambos formatos mantiene dos analizadores con dos modos de fallo disjuntos, páginas de códigos y tipos PROPVARIANT por un lado, espacios de nombres y partes opcionales por otro, y ninguno lee el formato del otro. Esa carga de mantenimiento es el argumento a favor de una librería nativa: HotXLS, la librería de hojas de cálculo en Object Pascal de losLab para Delphi y C++Builder en Windows, expone los mismos campos como simples propiedades del libro, Title, Author, Company, Created, y el resto, rellenadas por Open tanto para .xls como para .xlsx, sin necesidad de instalar Excel y sin nada de la fontanería de contenedores descrita arriba. Lee las propiedades como parte de una apertura completa del libro en lugar de un sondeo de solo metadatos, por lo que encaja en canalizaciones que de todos modos van a tocar los datos de las celdas; la superficie completa de propiedades en ambas fachadas, incluido el lado de escritura, se cubre en nuestro artículo sobre cómo establecer las propiedades de documento de Excel con HotXLS
Nota: Las herramientas integrales de análisis y extracción de metadatos de Excel están disponibles en el HotXLS Delphi VCL Component