Попросіть конвеєр розсортувати десять тисяч електронних таблиць за автором, компанією чи датою останньої зміни, і найгірше, що він може зробити, це повністю відкрити кожну робочу книгу. Відповіді їдуть у властивостях документа самого файлу, у тому, що світ Office називає Document Summary Information: шар метаданих, який індексує Windows Search, за яким SharePoint розкладає файли й який Excel показує у своєму діалозі Properties. Цей шар важить щонайбільше кілобайти, і він лежить у добре задокументованому місці в обох форматах Excel. Хитрість у тому, щоб дістатися до нього з Delphi, не платячи за мільйон клітинок, які вам не потрібні
Реальних шляхів три, і різняться вони не так тим, що повертають, як тим, чого вимагають від машини, яка їх виконує. Автоматизація COM керує самим Excel і читає все, за настільними цінами. Формат .xls тримає свої властивості в потоках наборів властивостей OLE, які Windows розбере за вас. Формат .xlsx тримає їх у двох невеликих XML-частинах усередині zip, який RTL з Delphi відкриває самотужки. Далі йде робочий код для кожного варіанта, з прямо названою ціною
Шлях 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 := ''; // властивість існує, але їй ніколи не присвоювали значення
end;
end;
begin
Excel := CreateOleObject('Excel.Application');
try
Excel.DisplayAlerts := False;
Book := Excel.Workbooks.Open(FileName, 0, True); // лише для читання
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; // має виконатися на кожному шляху, інакше EXCEL.EXE лишиться висіти
Excel := Unassigned;
end;
end;
А тепер рахунок. Excel має бути встановлений на кожній машині, де виконується цей код, що само собою виключає більшість серверів, а політика підтримки Microsoft прямо каже, що Office не спроєктовано й не ліцензовано для автоматизації на боці сервера без нагляду. CreateOleObject запускає повноцінний EXCEL.EXE, а Workbooks.Open розбирає всю робочу книгу, тож очікуйте приблизно від двох до чотирьох секунд на файл, перш ніж повернеться перша властивість. І try..finally навколо Quit тут не для краси: виняток, що вислизне між CreateOleObject і Quit, лишає осиротілий EXCEL.EXE, який тримає блокування на файлі, невидиме доти, доки наступний запуск на ньому не впаде. Повторне використання одного екземпляра Excel на весь пакет розподіляє вартість запуску, але концентрує ризик, бо один випадковий діалог на прихованому робочому столі зупиняє кожен файл, що стоїть у черзі за ним
Шлях 2: .xls зберігає властивості в потоках наборів властивостей OLE
Робоча книга BIFF8 є складеним файлом OLE, мініатюрною файловою системою зі сховищ і потоків. Дані клітинок живуть у потоці Workbook; метадані лежать поруч у двох потоках наборів властивостей, чиї імена починаються з керуючого символу #5: \005SummaryInformation для класичних полів і \005DocumentSummaryInformation для розширених та власних. Усередині кожного сидить двійковий набір властивостей у компонуванні MS-OLEPS, із секціями за ключем ідентифікатора формату (FMTID) і властивостями за ключем цілочислового ідентифікатора властивості. Секція зведення має FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, де PIDSI_TITLE дорівнює $02, а PIDSI_AUTHOR дорівнює $04; Company ($0F) і Manager ($0E) живуть у секції зведення документа, а власні властивості - у другій секції за словником імен
Добра новина в тому, що на 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: властивості немає
try
case Value.vt of
VT_LPSTR: Result := string(AnsiString(Value.pszVal));
VT_LPWSTR: Result := Value.pwszVal;
end;
finally
PropVariantClear(Value);
end;
end;
// використання: 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 Delphi VCL Component