Artículo técnico

Extracción de metadatos de Excel en Delphi

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

Cuadro de tres rutas de Delphi hacia la Document Summary Information de Excel: automatización COM controlando el propio Excel, flujos de property-set OLE para ficheros xls, y análisis XML de docProps OOXML para paquetes xlsx
La automatización COM compra cobertura total al precio de un Excel de escritorio con licencia y segundos por archivo, mientras que las dos vías nativas de formato leen solo contenedores de metadatos en milisegundos. Lo que cada vía devuelve es casi lo mismo — lo que exige a la máquina anfitriona no

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

Anatomía en Delphi de un fichero compuesto xls BIFF8 que coloca el stream Workbook junto a los property sets SummaryInformation y DocumentSummaryInformation, con la cadena de acceso StgOpenStorageEx a IPropertySetStorage
Un archivo xls almacena los datos de celdas y las propiedades del documento como flujos hermanos en un archivo compuesto OLE. Windows analiza por usted los conjuntos de propiedades binarios, de modo que el código Delphi no toca a mano ni los diseños MS-OLEPS ni las páginas de código

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

Delphi: Disposición de un paquete zip xlsx que muestra los miembros XML core, app y custom de docProps junto a las partes de worksheet, con las reglas de producción para sondear partes opcionales y hacer coincidir espacios de nombres
Los datos de la hoja dominan un paquete xlsx, pero los metadatos residen en tres pequeños miembros opcionales a su lado. El acceso aleatorio vía el directorio central del zip mantiene la lectura proporcional a las propiedades, no al libro
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