Article technique

Extraction d'informations de résumé de document à partir de fichiers Excel dans Delphi

Demandez à un pipeline d'acheminer dix mille feuilles de calcul par auteur, entreprise ou date de dernière modification, et la pire chose qu'il puisse faire est d'ouvrir complètement chaque classeur. Les réponses se trouvent dans les propriétés de document du fichier, ce que le monde Office appelle Document Summary Information (Informations de résumé du document) : la couche de métadonnées que Windows Search indexe, sur laquelle SharePoint s'appuie pour classer, et qu'Excel affiche dans sa boîte de dialogue Propriétés. Cette couche fait quelques kilo-octets au maximum, et elle réside dans un endroit bien documenté dans les deux formats Excel. L'astuce consiste à l'atteindre depuis Delphi sans payer pour le million de cellules dont vous n'avez pas besoin

Il existe trois véritables voies, et elles diffèrent moins par ce qu'elles renvoient que par ce qu'elles exigent de la machine qui les exécute. L'automatisation COM pilote Excel lui-même et lit tout, aux prix de ressources d'un bureau. Le format .xls conserve ses propriétés dans des flux d'ensembles de propriétés OLE que Windows analysera pour vous. Le format .xlsx les conserve dans deux petites parties XML à l'intérieur d'un zip que la RTL Delphi peut ouvrir seule. Le code de travail pour chacun suit, avec les coûts clairement énoncés

Voie 1 : L'automatisation COM lit tout, aux prix des ressources d'un bureau

L'automatisation est la seule voie offrant une couverture totale via un seul modèle d'objet : l'ensemble de résumés standard, l'ensemble étendu avec l'entreprise (Company) et le responsable (Manager), et les propriétés personnalisées définies par l'utilisateur, toutes accessibles via BuiltinDocumentProperties et CustomDocumentProperties. Tout arrive sous la forme d'un OleVariant, et l'API a une habitude qu'il vaut mieux connaître avant qu'elle ne frappe : une propriété intégrée qui n'a jamais été assignée ne revient pas vide, elle déclenche une EOleException au moment où vous touchez Value. L'assistant ci-dessous traite cela comme "non défini" plutôt que comme un échec

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 := '';   // property exists but was never assigned
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // read-only
    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;   // reach this on every path, or EXCEL.EXE stays behind
    Excel := Unassigned;
  end;
end;

Maintenant l'addition. Excel doit être installé sur chaque machine où ce code s'exécute, ce qui exclut la plupart des serveurs en soi, et la politique de support de Microsoft indique explicitement qu'Office n'est ni conçu ni sous licence pour l'automatisation côté serveur sans surveillance. CreateOleObject lance un EXCEL.EXE complet et Workbooks.Open analyse l'intégralité du classeur, attendez-vous donc à environ deux à quatre secondes par fichier avant le retour de la première propriété. Et le try..finally autour de Quit n'est pas décoratif : une exception qui s'échappe entre CreateOleObject et Quit laisse un EXCEL.EXE orphelin qui maintient un verrou sur le fichier, invisible jusqu'à ce que la prochaine exécution échoue contre lui. Réutiliser une instance d'Excel pour un lot amortit le coût de démarrage mais concentre les risques, car une simple boîte de dialogue égarée sur le bureau caché bloque chaque fichier mis en file d'attente derrière elle

Voie 2 : .xls stocke les propriétés dans des flux d'ensembles de propriétés OLE

Un classeur BIFF8 est un fichier composé OLE, un système de fichiers miniature composé de stockages (storages) et de flux (streams). Les données des cellules se trouvent dans le flux Workbook ; les métadonnées se trouvent à côté, dans deux flux d'ensembles de propriétés dont les noms commencent par le caractère de contrôle #5 : \005SummaryInformation pour les champs classiques et \005DocumentSummaryInformation pour les champs étendus et personnalisés. À l'intérieur de chacun se trouve un ensemble de propriétés binaire au format MS-OLEPS, avec des sections indexées par un identifiant de format (FMTID) et des propriétés indexées par un ID de propriété entier. La section de résumé est FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, où PIDSI_TITLE est $02 et PIDSI_AUTHOR est $04 ; l'entreprise ($0F) et le responsable ($0E) résident dans la section document-summary, et les propriétés personnalisées dans une seconde section derrière un dictionnaire de noms

La bonne nouvelle est que sous Windows, vous n'analysez jamais ces octets vous-même. Le stockage structuré (Structured storage) expose les flux via IPropertySetStorage, et ce qui suit se compile tel quel avec les unités 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: not present
  try
    case Value.vt of
      VT_LPSTR:  Result := string(AnsiString(Value.pszVal));
      VT_LPWSTR: Result := Value.pwszVal;
    end;
  finally
    PropVariantClear(Value);
  end;
end;

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

Un mot honnête sur ce que cache cet extrait. Les chaînes peuvent arriver sous la forme VT_LPWSTR ou VT_LPSTR, et dans le cas ANSI, les octets sont encodés dans la propre page de codes de l'ensemble de propriétés, elle-même stockée en tant que propriété 1 de la section. Le cast (transtypage) ci-dessus n'est donc exact que lorsque cette page de codes correspond à celle du système. Les horodatages (timestamps) reviennent en tant que VT_FILETIME en UTC. Les propriétés personnalisées impliquent l'ouverture de la section définie par l'utilisateur, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, et le parcours de son dictionnaire de noms. IPropertyStorage absorbe tout cela sous Windows ; écrire votre propre analyseur MS-OLEPS pour un environnement sans stockage structuré est un véritable projet, pas l'affaire d'un après-midi

Voie 3 : .xlsx conserve les docProps au format XML dans le zip

C'est la voie dont la plupart des pipelines ont réellement besoin, puisque les nouveaux fichiers sont des .xlsx depuis près de deux décennies. Un classeur OOXML est un package zip, et ses propriétés sont réparties en petites parties selon leur objectif : docProps/core.xml contient les champs Dublin Core, dc:title, dc:creator, cp:lastModifiedBy, plus dcterms:created et dcterms:modified sous forme d'horodatages W3CDTF en UTC, tandis que docProps/app.xml contient des champs de niveau application tels que Company et AppVersion, et docProps/custom.xml contient les propriétés personnalisées. Parce que le répertoire central du zip localise directement chaque partie, leur lecture coûte quelques kilo-octets quelle que soit la taille du classeur. TZipFile et IXMLDocument, tous deux inclus dans la RTL fournie, font tout le travail

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;

Deux détails rendent cela robuste en production. Tout d'abord, les parties sont facultatives : un package minimal sans aucun docProps est parfaitement valide selon l'ECMA-376, c'est pourquoi le code teste avec IndexOf au lieu de supposer quoi que ce soit. Deuxièmement, faites correspondre les éléments par nom local et URI d'espace de noms, comme FindNode le fait ci-dessus, jamais par préfixe littéral ; dc: et cp: sont des conventions du rédacteur d'Excel, et les fichiers produits par d'autres générateurs sont libres de choisir des préfixes différents. Une note environnementale : le fournisseur IXMLDocument par défaut est MSXML, donc une application console ou un thread de travail doit appeler CoInitialize avant LoadXMLData, sinon la première analyse meurt avec une erreur COM

La feuille de coûts, et quand une bibliothèque bat les deux analyseurs

Mesurée sur une machine de développeur ordinaire, la voie COM prend environ deux à quatre secondes par fichier lorsque la session d'automatisation est créée par fichier, ce qui correspond presque entièrement au démarrage d'EXCEL.EXE et à une analyse complète du classeur, et cela nécessite un Excel installé et sous licence partout où il s'exécute. Les deux voies directes ne lisent que les conteneurs de métadonnées, se terminent en quelques millisecondes par fichier et ne nécessitent rien d'autre que ce qu'un exécutable Delphi intègre déjà (links in). Sur un partage de dix mille fichiers, c'est la différence entre la majeure partie d'une journée de travail et moins d'une minute, sans aucune question de déploiement d'Office associée

Le hic avec les voies directes, c'est qu'il y en a deux. Un pipeline qui accepte les deux formats maintient deux analyseurs avec deux modes de défaillance disjoints, les pages de codes et les types PROPVARIANT d'un côté, les espaces de noms et les parties facultatives de l'autre, et aucun ne lit le format de l'autre. Cette charge de maintenance est le propre d'une bibliothèque native : HotXLS, la bibliothèque de feuilles de calcul Object Pascal de losLab pour Delphi et C++Builder sous Windows, expose les mêmes champs que les propriétés de classeur ordinaires (Titre, Auteur, Entreprise, Créé, etc.), renseignés par Open pour .xls et .xlsx de la même manière, sans installation d'Excel et sans la plomberie des conteneurs ci-dessus. Elle lit les propriétés dans le cadre d'une ouverture complète du classeur plutôt que d'une analyse des métadonnées seules, ce qui convient aux pipelines qui vont de toute façon manipuler les données des cellules ; toute la surface des propriétés sur les deux façades, y compris le côté écriture, est couverte dans notre article sur la définition des propriétés de documents Excel avec HotXLS

Remarque : Des outils complets d'analyse Excel et d'extraction de métadonnées sont disponibles dans le composant VCL HotXLS