Технічна стаття

Читання властивостей документа Excel у Delphi: три шляхи

Попросіть конвеєр розсортувати десять тисяч електронних таблиць за автором, компанією чи датою останньої зміни, і найгірше, що він може зробити, це повністю відкрити кожну робочу книгу. Відповіді їдуть у властивостях документа самого файлу, у тому, що світ Office називає Document Summary Information: шар метаданих, який індексує Windows Search, за яким SharePoint розкладає файли й який Excel показує у своєму діалозі Properties. Цей шар важить щонайбільше кілобайти, і він лежить у добре задокументованому місці в обох форматах Excel. Хитрість у тому, щоб дістатися до нього з Delphi, не платячи за мільйон клітинок, які вам не потрібні

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

Схема трьох шляхів Delphi до Document Summary Information в Excel: автоматизація COM, що керує самим Excel, потоки наборів властивостей OLE для файлів xls та розбір XML docProps в OOXML для xlsx
Автоматизація COM купує повне покриття ціною ліцензованого настільного Excel та секунд на файл, тоді як два формато-нативні шляхи читають лише контейнери метаданих за мілісекунди. Те, що повертає кожен шлях, майже однакове — а от вимоги до хост-машини ні

Шлях 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) живуть у секції зведення документа, а власні властивості - у другій секції за словником імен

Анатомія складеного файлу BIFF8 xls у Delphi: потік Workbook поруч із наборами властивостей SummaryInformation та DocumentSummaryInformation і ланцюжок доступу від StgOpenStorageEx до IPropertySetStorage
Файл xls зберігає дані клітинок і властивості документа як сусідні потоки у складеному файлі OLE. Windows розбере двійкові набори властивостей за вас, тож код на Delphi не торкається ані компонувань MS-OLEPS, ані кодових сторінок вручну

Добра новина в тому, що на 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, виконують усю роботу

Delphi: компонування zip-пакета xlsx із частинами docProps core, app і custom XML поруч із частинами аркушів, а також правила промислового коду для перевірки необов'язкових частин і зіставлення просторів імен
Дані аркушів домінують у пакеті xlsx, проте метадані сидять поруч у трьох невеликих необов'язкових частинах. Довільний доступ через центральний каталог zip тримає читання пропорційним до властивостей, а не до робочої книги
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