Попросите конвейер распределить десять тысяч электронных таблиц по автору, компании или дате последнего изменения, и худшее, что он может сделать, — полностью открыть каждую книгу. Ответы лежат в свойствах документа файла, в мире 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