Teknisk artikel

Läsa Excel-dokumentegenskaper i Delphi: tre vägar

Be en pipeline att sortera tiotusen kalkylblad efter författare, företag eller senaste ändringsdatum, och det värsta den kan göra är att öppna varje arbetsbok fullt ut. Svaren åker i filens dokumentegenskaper, det som Office-världen kallar Document Summary Information: det metadatalager som Windows Search indexerar, som SharePoint arkiverar efter och som Excel visar i sin egenskapsdialog. Det lagret är på sin höjd några kilobyte, och det ligger på en väldokumenterad plats i båda Excel-formaten. Konststycket är att nå det från Delphi utan att betala för de miljoner celler du inte behöver

Det finns tre verkliga vägar, och de skiljer sig mindre i vad de returnerar än i vad de kräver av maskinen som kör dem. COM-automation styr Excel självt och läser allt, till skrivbordspriser. Formatet .xls håller sina egenskaper i OLE-egenskapsuppsättningsströmmar som Windows tolkar åt dig. Formatet .xlsx håller dem i två små XML-delar inuti en zip som Delphis RTL kan öppna på egen hand. Fungerande kod för var och en följer, med kostnaderna rakt uttalade

Diagram över tre Delphi-vägar till Excels Document Summary Information: COM-automation som styr Excel självt, OLE-egenskapsuppsättningsströmmar för xls-filer och tolkning av docProps-XML i OOXML för xlsx-paket
COM-automation köper full täckning till priset av ett licensierat skrivbords-Excel och sekunder per fil, medan de två formatnära vägarna läser enbart metadatabehållare på millisekunder. Vad varje väg returnerar är nästan detsamma — vad den kräver av värdmaskinen är det inte

Väg 1: COM-automation läser allt, till skrivbordspriser

Automation är den enda vägen med full täckning genom en enda objektmodell: standarduppsättningen med sammanfattningar, den utökade uppsättningen med Company och Manager, och användardefinierade egna egenskaper, alla nåbara genom BuiltinDocumentProperties och CustomDocumentProperties. Allt kommer som en OleVariant, och API:et har en vana värd att känna till innan den biter: en inbyggd egenskap som aldrig tilldelats kommer inte tillbaka tom, den utlöser ett EOleException i samma stund du rör Value. Hjälpfunktionen nedan behandlar det som ”inte satt” snarare än som ett fel

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 := '';   // egenskapen finns men tilldelades aldrig
    end;
  end;

begin
  Excel := CreateOleObject('Excel.Application');
  try
    Excel.DisplayAlerts := False;
    Book := Excel.Workbooks.Open(FileName, 0, True);   // skrivskyddad
    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;   // nå hit på varje väg, annars blir EXCEL.EXE kvar
    Excel := Unassigned;
  end;
end;

Nu till notan. Excel måste vara installerat på varje maskin där den här koden körs, vilket i sig utesluter de flesta servrar, och Microsofts supportpolicy är uttrycklig med att Office varken är konstruerat eller licensierat för obevakad automation på serversidan. CreateOleObject startar en fullständig EXCEL.EXE och Workbooks.Open tolkar hela arbetsboken, så räkna med ungefär två till fyra sekunder per fil innan den första egenskapen kommer tillbaka. Och try..finally runt Quit är ingen utsmyckning: ett undantag som slinker ut mellan CreateOleObject och Quit lämnar kvar en föräldralös EXCEL.EXE som håller ett lås på filen, osynlig tills nästa körning misslyckas mot den. Att återanvända en Excel-instans över en batch fördelar startkostnaden men koncentrerar risken, eftersom en enda vilsen dialogruta på det dolda skrivbordet stoppar varje fil som köar bakom den

Väg 2: .xls lagrar egenskaper i OLE-egenskapsuppsättningsströmmar

En BIFF8-arbetsbok är en sammansatt OLE-fil, ett filsystem i miniatyr av lagringar och strömmar. Celldatan bor i strömmen Workbook; metadatan bor bredvid den i två egenskapsuppsättningsströmmar vars namn börjar med styrtecknet #5: \005SummaryInformation för de klassiska fälten och \005DocumentSummaryInformation för de utökade och egna. Inuti var och en sitter en binär egenskapsuppsättning i MS-OLEPS-layouten, med sektioner nycklade av en formatidentifierare (FMTID) och egenskaper nycklade av ett heltals-egenskaps-ID. Sammanfattningssektionen är FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}, där PIDSI_TITLE är $02 och PIDSI_AUTHOR är $04; Company ($0F) och Manager ($0E) bor i dokumentsammanfattningssektionen, och egna egenskaper i en andra sektion bakom en namnordbok

Delphi-anatomi över en sammansatt BIFF8-xls-fil som placerar Workbook-strömmen bredvid egenskapsuppsättningarna SummaryInformation och DocumentSummaryInformation, med åtkomstkedjan från StgOpenStorageEx till IPropertySetStorage
En xls-fil lagrar celldata och dokumentegenskaper som syskonströmmar i en sammansatt OLE-fil. Windows tolkar de binära egenskapsuppsättningarna åt dig, så Delphi-koden rör varken MS-OLEPS-layouter eller teckentabeller för hand

Den goda nyheten är att du på Windows aldrig tolkar de byten själv. Strukturerad lagring exponerar strömmarna genom IPropertySetStorage, och följande kompilerar som den står mot RTL:ens standardenheter

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: finns inte
  try
    case Value.vt of
      VT_LPSTR:  Result := string(AnsiString(Value.pszVal));
      VT_LPWSTR: Result := Value.pwszVal;
    end;
  finally
    PropVariantClear(Value);
  end;
end;

// användning: Writeln('Author: ', ReadXlsSummaryString('ledger.xls', PIDSI_AUTHOR));

Ett ärligt ord om vad kodsnutten döljer. Strängar kan komma som VT_LPWSTR eller som VT_LPSTR, och i ANSI-fallet är byten kodade i egenskapsuppsättningens egen teckentabell, som själv lagras som egenskap 1 i sektionen, så typomvandlingen ovan är bara exakt när den teckentabellen matchar systemets. Tidsstämplar kommer tillbaka som VT_FILETIME i UTC. Egna egenskaper innebär att öppna den användardefinierade sektionen, FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}, och gå igenom dess namnordbok. IPropertyStorage absorberar allt det på Windows; att skriva en egen MS-OLEPS-tolk för en miljö utan strukturerad lagring är ett riktigt projekt, inte en eftermiddag

Väg 3: .xlsx håller docProps som XML inuti zip-filen

Det här är vägen de flesta pipelines faktiskt behöver, eftersom nya filer varit .xlsx i snart två decennier. En OOXML-arbetsbok är ett zip-paket, och dess egenskaper är uppdelade över små delar efter syfte: docProps/core.xml håller Dublin Core-fälten, dc:title, dc:creator, cp:lastModifiedBy, plus dcterms:created och dcterms:modified som W3CDTF-tidsstämplar i UTC, medan docProps/app.xml håller fält på applikationsnivå såsom Company och AppVersion, och docProps/custom.xml håller egna egenskaper. Eftersom zip-filens centralkatalog lokaliserar varje del direkt kostar en läsning av dem några kilobyte oavsett hur stor arbetsboken är. TZipFile och IXMLDocument, båda i den levererade RTL:en, gör hela jobbet

Delphi: Layout av ett xlsx-zip-paket som visar docProps-medlemmarna core, app och custom i XML bredvid kalkylbladsdelarna, med produktionsreglerna för att sondera valfria delar och matcha namnrymder
Kalkylbladsdatan dominerar ett xlsx-paket, men metadatan sitter i tre små valfria medlemmar bredvid den. Direktåtkomst via zip-filens centralkatalog håller läsningen proportionell mot egenskaperna, inte mot arbetsboken
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;

Två detaljer håller det här robust i produktion. För det första är delarna valfria: ett minimalt paket helt utan docProps är fullt giltigt enligt ECMA-376, vilket är varför koden sonderar med IndexOf istället för att anta. För det andra, matcha element på lokalt namn och namnrymds-URI, som FindNode gör ovan, aldrig på literalt prefix; dc: och cp: är konventioner hos Excels skrivare, och filer framställda av andra generatorer får välja andra prefix. En anmärkning om miljön: standardleverantören för IXMLDocument är MSXML, så en konsolapplikation eller arbetstråd måste anropa CoInitialize före LoadXMLData, annars dör den första tolkningen med ett COM-fel

Kostnadsbladet, och när ett bibliotek slår båda tolkarna

Mätt på en vanlig utvecklarmaskin landar COM-vägen på ungefär två till fyra sekunder per fil när automationssessionen skapas per fil, nästan allt är start av EXCEL.EXE plus en fullständig tolkning av arbetsboken, och den kräver ett installerat, licensierat Excel överallt där den körs. De två direkta vägarna läser enbart metadatabehållarna, blir klara på ensiffriga millisekunder per fil, och behöver inget installerat utöver det en Delphi-körbar fil redan länkar in. Över en filutdelning med tiotusen filer är det skillnaden mellan större delen av en arbetsdag och under en minut, utan någon fråga om Office-utrullning på köpet

Haken med de direkta vägarna är att de är två. En pipeline som tar emot båda formaten underhåller två tolkar med två åtskilda felbeteenden, teckentabeller och PROPVARIANT-typer på ena sidan, namnrymder och valfria delar på den andra, och ingen av dem läser den andras format. Den underhållsbördan är argumentet för ett inbyggt bibliotek: HotXLS, losLabs kalkylbladsbibliotek i Object Pascal för Delphi och C++Builder på Windows, exponerar samma fält som vanliga arbetsboksegenskaper, Title, Author, Company, Created och resten, ifyllda av Open för både .xls och .xlsx, utan någon Excel-installation och utan något av rörmokeriet med behållare ovan. Det läser egenskaper som en del av en fullständig öppning av arbetsboken snarare än en sondering av enbart metadata, så det passar pipelines som ändå ska röra celldatan; hela egenskapsytan på båda fasaderna, inklusive skrivsidan, täcks i vår artikel om att sätta Excel-dokumentegenskaper med HotXLS

Observera: Fullständiga verktyg för Excel-tolkning och metadataextraktion finns i HotXLS Delphi VCL Component