Техническая статья

Извлечение сводной информации документа из файлов Excel в Delphi

Попросите конвейер распределить десять тысяч электронных таблиц по автору, компании или дате последнего изменения, и худшее, что он может сделать, — полностью открыть каждую книгу. Ответы лежат в свойствах документа файла, в мире Office это называется Document Summary Information: слой метаданных, который индексирует Windows Search, по которому SharePoint систематизирует файлы, а Excel показывает его в диалоге свойств. Этот слой занимает максимум килобайты и располагается в хорошо задокументированном месте в обоих форматах Excel. Хитрость в том, чтобы добраться до него из Delphi, не расплачиваясь за миллион ненужных ячеек

Есть три реальных пути, и различаются они не столько тем, что возвращают, сколько тем, чего требуют от машины, на которой выполняются. COM-автоматизация управляет самим Excel и читает всё, по настольным ценам. Формат .xls хранит свои свойства в потоках OLE property-set, которые Windows разберёт за вас. Формат .xlsx хранит их в двух небольших XML-частях внутри zip-архива, который Delphi RTL умеет открывать самостоятельно. Далее приводится рабочий код для каждого пути, а затраты названы прямо

Путь 1: COM-автоматизация читает всё, по настольным ценам

Автоматизация — единственный путь с полным охватом через одну объектную модель: стандартный сводный набор, расширенный набор с Company и Manager, а также определённые пользователем пользовательские свойства, всё доступно через BuiltinDocumentProperties и CustomDocumentProperties. Всё приходит как OleVariant, и у API есть одна привычка, о которой стоит знать, прежде чем она вас укусит: встроенное свойство, которому никогда не присваивали значения, не возвращается пустым, а вызывает EOleException в тот момент, когда вы обращаетесь к Value. Вспомогательная функция ниже трактует это как «не задано», а не как сбой

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;

Теперь счёт. Excel должен быть установлен на каждой машине, где выполняется этот код, что уже само по себе исключает большинство серверов, а политика поддержки Microsoft прямо указывает, что Office не спроектирован и не лицензирован для серверной автоматизации без участия оператора. CreateOleObject запускает полноценный EXCEL.EXE, а Workbooks.Open разбирает всю книгу целиком, поэтому ожидайте примерно от двух до четырёх секунд на файл, прежде чем вернётся первое свойство. И try..finally вокруг Quit — не украшение: исключение, ускользнувшее между CreateOleObject и Quit, оставляет осиротевший EXCEL.EXE, удерживающий блокировку файла, невидимый до тех пор, пока следующий запуск не споткнётся об него. Повторное использование одного экземпляра Excel в пакете амортизирует затраты на запуск, но концентрирует риск, потому что один случайный диалог на скрытом рабочем столе застопорит каждый файл в очереди за ним

Путь 2: .xls хранит свойства в потоках OLE property-set

Книга BIFF8 — это составной OLE-файл, миниатюрная файловая система из хранилищ и потоков. Данные ячеек лежат в потоке Workbook; метаданные лежат рядом в двух потоках property-set, чьи имена начинаются с управляющего символа #5: \005SummaryInformation для классических полей и \005DocumentSummaryInformation для расширенных и пользовательских. Внутри каждого находится двоичный набор свойств в разметке MS-OLEPS, с секциями, ключом к которым служит идентификатор формата (FMTID), и свойствами, ключом к которым служит целочисленный идентификатор свойства. Сводная секция — это FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, где PIDSI_TITLE равен $02, а PIDSI_AUTHOR равен $04; Company ($0F) и Manager ($0E) находятся в секции document-summary, а пользовательские свойства — во второй секции за словарём имён

Хорошая новость в том, что в Windows вам никогда не приходится разбирать эти байты самостоятельно. Структурированное хранилище открывает эти потоки через IPropertySetStorage, и следующий код компилируется как есть на стандартных модулях 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: 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));

Честное слово о том, что скрывает этот фрагмент. Строки могут приходить как VT_LPWSTR или как VT_LPSTR, и в случае ANSI байты закодированы в собственной кодовой странице набора свойств, которая сама хранится как свойство 1 секции, поэтому приведение выше точно только тогда, когда эта кодовая страница совпадает с системной. Метки времени возвращаются как VT_FILETIME в UTC. Пользовательские свойства означают открытие определённой пользователем секции, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, и обход её словаря имён. IPropertyStorage берёт всё это на себя в Windows; написание собственного парсера MS-OLEPS для окружения без структурированного хранилища — это настоящий проект, а не работа на полдня

Путь 3: .xlsx хранит docProps как XML внутри zip-архива

Это тот путь, который большинству конвейеров на самом деле и нужен, поскольку новые файлы имеют формат .xlsx уже почти два десятилетия. Книга OOXML — это zip-пакет, и её свойства разделены по назначению на небольшие части: docProps/core.xml хранит поля Dublin Core, dc:title, dc:creator, cp:lastModifiedBy, а также dcterms:created и dcterms:modified как метки времени W3CDTF в UTC, тогда как docProps/app.xml хранит поля уровня приложения, такие как Company и AppVersion, а docProps/custom.xml хранит пользовательские свойства. Поскольку центральный каталог zip-архива определяет местоположение каждой части напрямую, их чтение стоит нескольких килобайт независимо от того, насколько велика книга. TZipFile и IXMLDocument, оба в поставляемой RTL, выполняют всю работу

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;

Две детали делают это надёжным в продакшене. Во-первых, части необязательны: минимальный пакет вообще без docProps совершенно допустим по ECMA-376, поэтому код проверяет через IndexOf, а не предполагает наличие. Во-вторых, сопоставляйте элементы по локальному имени и URI пространства имён, как это делает FindNode выше, а не по буквальному префиксу; dc: и cp: — это соглашения записывающего кода Excel, а файлы, созданные другими генераторами, вольны выбирать другие префиксы. Одно замечание об окружении: стандартный поставщик IXMLDocument — это MSXML, поэтому консольное приложение или рабочий поток обязаны вызвать CoInitialize перед LoadXMLData, иначе первый разбор погибнет с COM-ошибкой

Таблица затрат и когда библиотека превосходит оба парсера

По замерам на обычной машине разработчика путь через COM выходит примерно на две-четыре секунды на файл, когда сессия автоматизации создаётся на каждый файл, почти всё это — запуск EXCEL.EXE плюс полный разбор книги, и он требует установленного, лицензированного Excel везде, где выполняется. Два прямых пути читают только контейнеры метаданных, завершаются за однозначное число миллисекунд на файл и не требуют ничего установленного сверх того, что исполняемый файл Delphi уже линкует в себя. На общей папке из десяти тысяч файлов это разница между большей частью рабочего дня и менее чем минутой, без всякого сопутствующего вопроса о развёртывании Office

Подвох прямых путей в том, что их два. Конвейер, принимающий оба формата, сопровождает два парсера с двумя непересекающимися режимами отказа, кодовые страницы и типы PROPVARIANT с одной стороны, пространства имён и необязательные части с другой, и ни один не читает формат другого. Именно эта нагрузка на сопровождение — довод в пользу нативной библиотеки: HotXLS, библиотека электронных таблиц на Object Pascal от losLab для Delphi и C++Builder в Windows, предоставляет те же поля как обычные свойства книги, Title, Author, Company, Created и остальные, заполняемые методом Open одинаково для .xls и .xlsx, без установки Excel и без всей описанной выше обвязки контейнеров. Она читает свойства как часть полного открытия книги, а не как зондирование одних лишь метаданных, поэтому подходит конвейерам, которые всё равно затем обращаются к данным ячеек; полная поверхность свойств на обоих фасадах, включая сторону записи, рассмотрена в нашей статье о задании свойств документа Excel с помощью HotXLS

Примечание: инструменты полного разбора Excel и извлечения метаданных доступны в HotXLS VCL Component