技術文章

在 Delphi 中讀取 Excel 文件屬性:三條路徑

要求一條管線依作者、公司或最後修改日期路由一萬份試算表,它能做的最糟的事,就是完整開啟每一份活頁簿。答案就搭在檔案的文件屬性裡,也就是 Office 世界所稱的文件摘要資訊:那層 Windows Search 為之建立索引、SharePoint 據以歸檔、而 Excel 在其屬性對話框中顯示的中繼資料。那層資料至多只有幾 KB,並在兩種 Excel 格式中都位於有文件記載的地方。訣竅是在 Delphi 中抵達它,而不為你不需要的百萬個儲存格付費

有三條真實的路徑,它們的差異不在於回傳什麼,而在於對執行它們的機器要求什麼。COM 自動化驅動 Excel 本身,什麼都讀,但以桌面級的代價。.xls 格式把它的屬性放在 Windows 會替你解析的 OLE 屬性集串流裡。.xlsx 格式把它們放在 zip 內的兩個小型 XML 部分裡,而 Delphi RTL 自己就能開啟。每一條路的工作程式碼如下,成本坦白陳述

以 Delphi 取得 Excel 文件摘要資訊的三種途徑圖表:以 COM 自動化驅動 Excel 本身、針對 xls 檔案的 OLE 屬性集串流,以及針對 xlsx 套件的 OOXML docProps XML 解析
COM 自動化換來完整覆蓋,代價是授權的桌面 Excel 與每檔數秒;兩條格式原生路線只讀中繼資料容器,毫秒即成。各路線傳回的內容幾乎相同 — 對主機的要求卻大不相同

路徑 1:COM 自動化什麼都讀,但以桌面級代價

自動化是唯一透過單一物件模型擁有完整涵蓋的路徑:標準的摘要集、帶有 Company 與 Manager 的延伸集,以及使用者自訂屬性,全部可透過 BuiltinDocumentPropertiesCustomDocumentProperties 抵達。一切都以 OleVariant 到達,而這個 API 有一個在它咬人之前值得知道的習慣:一個從未被指派的內建屬性,不會以空白回來,而是在你碰 Value 的那一刻引發 EOleException。下面的輔助函式把那當作「未設定」而非失敗

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 存在但從未指派
    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;   // 在每條路徑上都要走到這裡,否則 EXCEL.EXE 會留在背景
    Excel := Unassigned;
  end;
end;

現在來算帳。Excel 必須安裝在執行這段程式碼的每一台機器上,這本身就排除了大多數伺服器,而微軟的支援政策明確指出,Office 既非為無人值守的伺服器端自動化而設計,也未為此授權。CreateOleObject 啟動一個完整的 EXCEL.EXE,而 Workbooks.Open 解析整份活頁簿,所以預期在第一個屬性回來之前,每份檔案大約要兩到四秒。而圍繞 Quittry..finally 不是裝飾:一個在 CreateOleObjectQuit 之間逃出的例外,會留下一個持有該檔案鎖的孤兒 EXCEL.EXE,直到下一次執行對它失敗之前都看不見。在一個批次中重用一個 Excel 執行個體能攤提啟動成本,卻也集中了風險,因為隱藏桌面上的一個迷途對話框,會卡住排在它後面的每一份檔案

路徑 2:.xls 把屬性存放在 OLE 屬性集串流裡

一份 BIFF8 活頁簿是一個 OLE 複合檔案,一個由 storage 與串流組成的微型檔案系統。儲存格資料住在 Workbook 串流裡;中繼資料則住在它旁邊的兩個屬性集串流裡,其名稱以控制字元 #5 開頭:\005SummaryInformation 供經典欄位,\005DocumentSummaryInformation 供延伸與自訂欄位。每個裡面坐著一個 MS-OLEPS 配置的二進位屬性集,其區段以格式識別碼(FMTID)為索引鍵,屬性以整數屬性 ID 為索引鍵。摘要區段是 FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9},其中 PIDSI_TITLE$02PIDSI_AUTHOR$04;Company($0F)與 Manager($0E)住在文件摘要區段,而自訂屬性則在一個名稱字典之後的第二個區段裡

Delphi 剖析 BIFF8 xls 複合檔案: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: 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_LPWSTRVT_LPSTR 到達,而在 ANSI 的情況下,位元組是以屬性集自己的字碼頁編碼的——該字碼頁本身儲存為該區段的屬性 1——所以上面的轉型只有在該字碼頁與系統相符時才精確。時間戳記以 VT_FILETIME(UTC)回來。自訂屬性意味著開啟使用者定義區段,FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE},並走過它的名稱字典。IPropertySetStorage 在 Windows 上吸收了這一切;為一個沒有結構化儲存的環境寫自己的 MS-OLEPS 解析器,是一項真正的專案,而不是一個下午

路徑 3:.xlsx 把 docProps 作為 XML 存放在 zip 內

這是多數管線實際需要的路徑,因為新檔案幾乎二十年來都是 .xlsx。一個 OOXML 活頁簿是一個 zip 封包,而它的屬性依用途分到小型部分裡:docProps/core.xml 持有 Dublin Core 欄位、dc:titledc:creatorcp:lastModifiedBy,加上作為 UTC 中 W3CDTF 時間戳記的 dcterms:createddcterms:modifieddocProps/app.xml 持有如 Company 與 AppVersion 等應用程式層級欄位;docProps/custom.xml 則持有自訂屬性。因為 zip 的中央目錄直接定位每個部分,讀取它們無論活頁簿多大都只花幾 KB。隨附 RTL 中的 TZipFileIXMLDocument 能完成全部工作

Delphi:xlsx zip 套件版面圖: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 探測,而非假設它存在。第二,如上面的 FindNode 所做,依本機名稱與命名空間 URI 比對元素,絕不依字面前置詞;dc:cp: 是 Excel 寫入器的慣例,而由其他產生器產生的檔案可以自由挑選不同的前置詞。一則環境注意事項:預設的 IXMLDocument 供應商是 MSXML,所以主控台應用程式或工作者執行緒必須在 LoadXMLData 之前呼叫 CoInitialize,否則第一次解析會以一個 COM 錯誤告終

成本表,以及程式庫何時勝過兩個解析器

在一台普通的開發機上量測,COM 路徑在每次為每份檔案建立自動化工作階段時,每份檔案大約落在兩到四秒,幾乎全是 EXCEL.EXE 啟動加上一次完整活頁簿解析,而且它在任何執行之處都需要一個已安裝、已授權的 Excel。兩條直接路徑只讀中繼資料容器,每份檔案在個位數毫秒內完成,而且除了 Delphi 執行檔已經連結的東西之外,什麼都不需要安裝。在一份一萬個檔案的共用上,那就是大半天與不到一分鐘的差別,而且不附帶任何 Office 部署問題

直接路徑的麻煩在於有兩條。一條接受兩種格式的管線,維護著兩個帶有兩種互不相交失敗模式的解析器——一邊是字碼頁與 PROPVARIANT 型別,另一邊是命名空間與選用部分——而兩者都不讀對方的格式。那個維護負載,正是一個原生程式庫的理由:HotXLS,losLab 在 Windows 上供 Delphi 與 C++Builder 使用的 Object Pascal 試算表程式庫,把相同的欄位公開為普通的活頁簿屬性——Title、Author、Company、Created 等等——由 Open.xls.xlsx 一併填入,沒有 Excel 安裝,也沒有上面任何的容器配管。它把讀取屬性作為一次完整活頁簿開啟的一部分,而非一次僅中繼資料的探測,所以它適合那些反正會接著碰觸儲存格資料的管線;兩個外觀層上的完整屬性表面(包含寫入端)涵蓋於我們關於以 HotXLS 設定 Excel 文件屬性的文章

註:完整的 Excel 解析與中繼資料抽取工具,可在HotXLS Delphi VCL Component 取得