기술 문서

Delphi에서 Excel 문서 속성 읽기: 세 가지 경로

스프레드시트 1만 개를 작성자, 회사, 최종 수정일로 분류하라고 파이프라인에 시켰을 때 그것이 할 수 있는 최악의 행동은 통합 문서를 하나하나 완전히 여는 것입니다. 답은 파일의 문서 속성, Office 세계가 Document Summary Information이라 부르는 곳에 실려 있습니다. Windows 검색이 색인하고, SharePoint가 분류에 쓰고, Excel이 속성 대화 상자에 보여 주는 메타데이터 계층입니다. 그 계층은 커야 킬로바이트 단위이고, 두 Excel 형식 모두에서 잘 문서화된 자리에 놓여 있습니다. 요령은 필요 없는 백만 개의 셀에 값을 치르지 않고 Delphi에서 거기에 닿는 것입니다

실질적인 경로는 셋이고, 이들은 무엇을 반환하는지보다 실행 머신에 무엇을 요구하는지에서 더 크게 갈립니다. COM 자동화는 Excel 자체를 구동해 모든 것을 읽지만 데스크톱 값을 치릅니다. .xls 형식은 속성을 OLE 속성 집합 스트림에 두는데, Windows가 대신 파싱해 줍니다. .xlsx 형식은 zip 안의 작은 XML 파트 둘에 두며, Delphi RTL이 스스로 열 수 있습니다. 각각의 동작하는 코드를 비용과 함께 솔직하게 이어서 보이겠습니다

Excel Document Summary Information에 이르는 Delphi의 세 경로 도표: Excel 자체를 구동하는 COM 자동화, 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 := '';   // 속성은 있으나 한 번도 할당되지 않음
    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은 통합 문서 전체를 파싱하므로, 첫 속성이 돌아오기까지 파일당 대략 2~4초를 예상하십시오. 그리고 Quit를 감싼 try..finally는 장식이 아닙니다. CreateOleObjectQuit 사이에서 예외가 빠져나가면 파일에 잠금을 건 고아 EXCEL.EXE가 남고, 다음 실행이 그 잠금 때문에 실패하기 전까지는 보이지도 않습니다. Excel 인스턴스 하나를 배치 전체에서 재사용하면 시작 비용은 분산되지만 위험은 한곳에 몰립니다. 숨겨진 데스크톱에 대화 상자 하나가 뜨면 뒤에 줄 선 모든 파일이 멈추기 때문입니다

경로 2: .xls는 속성을 OLE 속성 집합 스트림에 저장합니다

BIFF8 통합 문서는 OLE 복합 파일, 즉 저장소와 스트림으로 이루어진 소형 파일 시스템입니다. 셀 데이터는 Workbook 스트림에 있고, 메타데이터는 그 옆에서 제어 문자 #5로 시작하는 이름의 속성 집합 스트림 둘에 있습니다. 고전적인 필드용 \005SummaryInformation과 확장 및 커스텀 필드용 \005DocumentSummaryInformation입니다. 각 스트림 안에는 MS-OLEPS 배치를 따르는 이진 속성 집합이 들어 있고, 섹션은 형식 식별자(FMTID)로, 속성은 정수 속성 ID로 지정됩니다. 요약 섹션은 FMTID {F29F85E0-4FF9-1068-AB91-08002B27B3D9}이며 여기서 PIDSI_TITLE$02, PIDSI_AUTHOR$04입니다. Company($0F)와 Manager($0E)는 문서 요약 섹션에 있고, 커스텀 속성은 이름 딕셔너리 뒤의 두 번째 섹션에 있습니다

Workbook 스트림을 SummaryInformation 및 DocumentSummaryInformation 속성 집합과 나란히 배치하고 StgOpenStorageEx에서 IPropertySetStorage로 이어지는 접근 사슬을 보여 주는 BIFF8 xls 복합 파일의 Delphi 해부도
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로 저장되어 있으므로, 위의 형 변환은 그 코드 페이지가 시스템의 것과 일치할 때에만 정확합니다. 타임스탬프는 UTC 기준 VT_FILETIME으로 돌아옵니다. 커스텀 속성을 다루려면 사용자 정의 섹션인 FMTID {D5CDD505-2E9C-101B-9397-08002B2CF9AE}를 열고 그 이름 딕셔너리를 순회해야 합니다. Windows에서는 IPropertyStorage가 그 모든 것을 흡수해 줍니다. 구조적 저장소가 없는 환경을 위해 MS-OLEPS 파서를 직접 쓰는 것은 오후 한나절 일이 아니라 진짜 프로젝트입니다

경로 3: .xlsx는 docProps를 zip 안의 XML로 보관합니다

새 파일이 .xlsx가 된 지 20년 가까이 되었으니, 대부분의 파이프라인이 실제로 필요로 하는 경로는 이것입니다. OOXML 통합 문서는 zip 패키지이고, 속성은 용도별로 작은 파트들에 나뉘어 있습니다. docProps/core.xml은 Dublin Core 필드인 dc:title, dc:creator, cp:lastModifiedBy와 UTC 기준 W3CDTF 타임스탬프인 dcterms:created, dcterms:modified를 담고, docProps/app.xml은 Company와 AppVersion 같은 애플리케이션 수준 필드를, docProps/custom.xml은 커스텀 속성을 담습니다. zip의 중앙 디렉터리가 각 파트를 직접 찾아 주므로, 통합 문서가 아무리 크더라도 이들을 읽는 비용은 몇 킬로바이트입니다. 둘 다 기본 제공 RTL에 들어 있는 TZipFileIXMLDocument가 이 일을 통째로 해냅니다

Delphi: 워크시트 파트와 나란히 docProps의 core, app, custom XML 멤버를 보여 주는 xlsx zip 패키지 배치도와, 선택적 파트를 탐지하고 네임스페이스를 맞추는 운영 규칙
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 경로는 파일당 대략 2~4초에 안착하고 그 대부분은 EXCEL.EXE 시작과 통합 문서 전체 파싱이며, 실행되는 곳마다 설치되고 라이선스된 Excel을 요구합니다. 직접적인 두 경로는 메타데이터 컨테이너만 읽어 파일당 한 자릿수 밀리초에 끝나고, Delphi 실행 파일이 이미 링크하고 있는 것 외에는 아무것도 설치할 필요가 없습니다. 파일 1만 개짜리 공유 폴더 전체로 보면 이것은 하루 근무 시간 대부분과 1분 미만의 차이이며, Office 배포 문제도 따라붙지 않습니다

직접적인 경로의 걸림돌은 그것이 둘이라는 점입니다. 두 형식을 모두 받는 파이프라인은 실패 양상이 전혀 겹치지 않는 파서 둘을 유지해야 합니다. 한쪽은 코드 페이지와 PROPVARIANT 타입, 다른 쪽은 네임스페이스와 선택적 파트이며, 어느 쪽도 상대의 형식을 읽지 못합니다. 그 유지 부담이 네이티브 라이브러리를 쓸 근거입니다. Windows용 Delphi 및 C++Builder를 위한 losLab의 Object Pascal 스프레드시트 라이브러리 HotXLS는 Title, Author, Company, Created를 비롯한 같은 필드를 평범한 통합 문서 속성으로 노출하며, .xls.xlsx 모두에서 Open이 이를 채워 줍니다. Excel 설치도, 위와 같은 컨테이너 배관 작업도 필요 없습니다. 메타데이터만 훑는 탐침이 아니라 통합 문서 전체 열기의 일부로 속성을 읽으므로, 어차피 셀 데이터까지 다룰 파이프라인에 잘 맞습니다. 쓰기 쪽을 포함해 두 형식 모두에서의 전체 속성 표면은 HotXLS로 Excel 문서 속성을 설정하는 글에서 다룹니다

참고: 완전한 Excel 파싱 및 메타데이터 추출 도구는 HotXLS Delphi VCL Component에서 제공됩니다