Artículo técnico

Leer propiedades de documentos Excel en Delphi: tres rutas

Pídale a un pipeline 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 por completo cada libro. Las respuestas viajan en las propiedades del documento del archivo, lo que el mundo Office llama Document Summary Information: la capa de metadatos que Windows Search indexa, por la que SharePoint clasifica y que Excel muestra en su diálogo de Propiedades. Esa capa pesa kilobytes como mucho, y vive en un lugar bien documentado en ambos formatos de Excel. El truco está en llegar a ella desde Delphi sin pagar por el millón de celdas que usted no necesita

Hay tres rutas reales, y difieren menos en lo que devuelven que en lo que exigen de la máquina que las ejecuta. La automatización COM maneja el propio Excel y lee todo, a precio de escritorio. El formato .xls guarda sus propiedades en flujos de conjuntos de propiedades OLE que Windows analizará por usted. El formato .xlsx las guarda en dos pequeñas partes XML dentro de un zip que la RTL de Delphi puede abrir por sí sola. A continuación hay código funcional para cada una, con los costos expuestos con claridad

Gráfico de tres rutas en Delphi hacia la Document Summary Information de Excel: automatización COM que maneja el propio Excel, flujos de conjuntos de propiedades OLE para archivos xls y análisis XML de docProps OOXML para paquetes xlsx
La automatización COM compra cobertura total al costo de un Excel de escritorio con licencia y segundos por archivo, mientras que las dos rutas nativas del formato leen solo los contenedores de metadatos en milisegundos. Lo que cada ruta devuelve es casi lo mismo — lo que exige de la máquina anfitriona no lo es

Ruta 1: la automatización COM 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 Company y Manager, y las propiedades personalizadas definidas por el usuario, todas accesibles mediante BuiltinDocumentProperties y CustomDocumentProperties. Todo llega como un OleVariant, y la API tiene un hábito que conviene conocer antes de que muerda: una propiedad integrada que nunca fue asignada no regresa vacía, sino que lanza una EOleException en el momento en que usted toca Value. El asistente de abajo trata eso como "no establecida" en lugar de como una falla

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 fue asignada
    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 toda ruta, o EXCEL.EXE queda atrás
    Excel := Unassigned;
  end;
end;

Ahora la cuenta. Excel debe estar instalado en cada máquina donde corre este código, lo que por sí solo descarta la mayoría de los servidores, y la política de soporte de Microsoft es explícita en que Office no está diseñado ni licenciado para automatización desatendida del lado del servidor. CreateOleObject lanza un EXCEL.EXE completo y Workbooks.Open analiza todo el libro, así que espere aproximadamente de dos a cuatro segundos por archivo antes de que regrese 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 sosteniendo un bloqueo sobre el archivo, invisible hasta que la siguiente ejecución falla contra él. Reutilizar una instancia de Excel a lo largo de un lote amortiza el costo de arranque pero concentra el riesgo, porque un solo diálogo perdido en el escritorio oculto detiene cada archivo en cola detrás de él

Ruta 2: .xls guarda las propiedades en flujos de conjuntos de propiedades OLE

Un libro BIFF8 es un archivo compuesto OLE, un sistema de archivos en miniatura de almacenamientos y flujos. Los datos de las celdas viven en el flujo Workbook; los metadatos viven a su lado en dos flujos de conjuntos de propiedades cuyos nombres comienzan con el carácter de control #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 diseño MS-OLEPS, con secciones indexadas por un identificador de formato (FMTID) y propiedades indexadas 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; Company ($0F) y Manager ($0E) viven en la sección de resumen del documento, y las propiedades personalizadas en una segunda sección detrás de un diccionario de nombres

Anatomía en Delphi de un archivo compuesto xls BIFF8 que ubica el flujo Workbook junto a los conjuntos de propiedades SummaryInformation y DocumentSummaryInformation, con la cadena de acceso de 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 analizará los conjuntos de propiedades binarios por usted, así que el código Delphi no toca a mano ni los diseños MS-OLEPS ni las páginas de códigos

La buena noticia es que en Windows usted nunca analiza esos bytes por su cuenta. El almacenamiento estructurado expone los flujos a través de IPropertySetStorage, y lo siguiente compila tal como se muestra contra las unidades estándar de la 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 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, así que la conversión de arriba solo es exacta cuando esa página de códigos coincide con la del sistema. Las marcas de tiempo regresan 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 absorbe todo eso en Windows; escribir su propio analizador MS-OLEPS para un entorno sin almacenamiento estructurado es un proyecto genuino, no una tarde

Ruta 3: .xlsx guarda docProps como XML dentro del zip

Esta es la ruta que la mayoría de los pipelines realmente necesita, ya que los archivos nuevos han sido .xlsx durante casi dos décadas. Un libro OOXML es un paquete zip, y sus propiedades están repartidas en partes pequeñas según su propósito: docProps/core.xml contiene los campos Dublin Core, dc:title, dc:creator, cp:lastModifiedBy, más dcterms:created y dcterms:modified como marcas de tiempo W3CDTF en UTC, mientras que docProps/app.xml contiene campos a nivel de aplicación como Company y AppVersion, y docProps/custom.xml contiene las propiedades personalizadas. Como el directorio central del zip ubica cada parte directamente, leerlas cuesta unos pocos kilobytes sin importar cuán grande sea el libro. TZipFile e IXMLDocument, ambos en la RTL incluida, hacen todo el trabajo

Delphi: diseño de un paquete zip xlsx que muestra los miembros XML core, app y custom de docProps junto a las partes de hoja de cálculo, con las reglas de producción para sondear partes opcionales y hacer coincidir espacios de nombres
Los datos de las hojas dominan un paquete xlsx, pero los metadatos se ubican en tres pequeños miembros opcionales a su lado. El acceso aleatorio a través del 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, y por eso el código sondea con IndexOf en lugar de dar por sentado. Segundo, haga coincidir los elementos por nombre local y URI de espacio de nombres, 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, así que una aplicación de consola o un hilo de trabajo debe llamar a CoInitialize antes de LoadXMLData, o el primer análisis muere con un error COM

La hoja de costos, y cuándo una biblioteca supera a ambos analizadores

Medida en una máquina de desarrollo ordinaria, la ruta COM se ubica aproximadamente en dos a cuatro segundos por archivo cuando la sesión de automatización se crea por archivo, casi todo por el arranque de EXCEL.EXE más un análisis completo del libro, y requiere un Excel instalado y con licencia dondequiera que corra. 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 Delphi ya enlaza. A lo largo de un recurso compartido de diez mil archivos, esa es la diferencia entre la mayor parte de una jornada laboral y menos de un minuto, sin ninguna pregunta de despliegue de Office de por medio

El inconveniente de las rutas directas es que son dos. Un pipeline que acepta ambos formatos mantiene dos analizadores con dos modos de falla disjuntos, páginas de códigos y tipos PROPVARIANT de un lado, espacios de nombres y partes opcionales del otro, y ninguno lee el formato del otro. Esa carga de mantenimiento es el argumento a favor de una biblioteca nativa: HotXLS, la biblioteca de hojas de cálculo en Object Pascal de losLab para Delphi y C++Builder en Windows, expone los mismos campos como propiedades simples del libro, Title, Author, Company, Created y el resto, rellenadas por Open tanto para .xls como para .xlsx, sin instalación de Excel y sin nada de la plomería de contenedores de arriba. Lee las propiedades como parte de una apertura completa del libro y no como un sondeo solo de metadatos, así que encaja en pipelines 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 establecer propiedades de documentos Excel con HotXLS

Nota: las herramientas completas de análisis de Excel y extracción de metadatos están disponibles en el componente VCL HotXLS para Delphi