기술 문서

델파이에서 COM IStorage 없이 OLE2 복합 파일 읽기

델파이와 C++Builder용 HotXLS Excel Library는 모든 레거시 .xls 파일 뒤에 있는 Compound File Binary 컨테이너를 순수 Object Pascal로 읽고 씁니다. TlxCompoundFile 클래스는 [MS-CFB] 버전 3 레이아웃 — 헤더, DIFAT, FAT 체인, MiniFAT, 디렉터리 트리 — 을 TStream에 대해 직접 구현하며, 그 경로 어디에도 ole32.dll이나 COM IStorage는 없습니다

배관 작업처럼 들리고, 지난 20년 동안 그것은 다른 누군가가 소유한 배관이었습니다. .xls 파일을 다루는 모든 델파이 코드베이스는 StgOpenStorage를 호출해 IStorage를 받아와 그 안에서 Workbook 스트림을 꺼냈습니다. 세 줄, 잘 작동했고, 아무도 다시 생각하지 않았습니다 — 같은 코드가 Windows가 아닌 곳에서 실행되어야 하는 날이 오기 전까지는

StgOpenStorage가 서버에서 작동을 멈추는 이유는 무엇인가

COM 구조적 저장소 API는 파일 형식과는 아무 관계 없는 이유로, 현대 델파이 코드가 살아가는 바로 그 배포 형태에서 실패합니다. StgOpenStorageole32.dll의 Win32 진입점입니다: 파일 시스템 상의 경로를 원하고, 호출 스레드에서 COM이 초기화되어 있기를 원하며, Windows 위에 있기를 원합니다. 경로 요구사항이 먼저 아픕니다. 업로드된 워크북을 받는 REST 엔드포인트는 바이트를 버퍼에 갖고 있지 디스크에 갖고 있지 않기 때문입니다 — 그래서 버퍼를 임시 파일에 쓰고, 그것을 열고, 다시 읽고, 삭제하게 되며, 이제 부하 아래서 잘못될 수 있는 임시 파일 수명 주기를 떠안게 됩니다. ILockBytes가 문서화된 탈출구이지만, TMemoryStream 위에 커스텀 구현을 배선하는 것은 대부분의 팀이 원하는 것보다 더 많은 COM 상호운용을 요구합니다. 초기화 요구사항은 두 번째로 물어뜯습니다. 보통 아무도 CoInitialize를 호출하지 않은 서비스 워커 스레드에서 일어납니다. 그리고 플랫폼 요구사항은 대상이 FPC 위의 Linux, 컨테이너 이미지, macOS인 순간 대화를 끝내버립니다. 그래서 HotXLS는 StgOpenStorage 위에 구축된 전통적인 lxOLE 경로를 기본값으로 유지합니다. 그 경로는 실전에서 검증되었고 기존 호출자가 바꿀 필요가 없어야 하기 때문입니다. TlxCompoundFile은 나머지 모든 사람을 위한 선택적 대안입니다

헤더와 FAT 체인이 실제로 말해주는 것

복합 파일의 첫 512바이트는 페이로드 바이트를 하나라도 읽기 전에 필요한 모든 구조적 질문에 답합니다. [MS-CFB] §2.2는 오프셋 0의 헤더 시그니처를 여덟 바이트 D0 CF 11 E0 A1 B1 1A E1로 고정하며, lxIsCompoundStream은 정확히 그것을 확인한 다음 스트림 위치를 원래대로 복원해서 호출자가 아무것도 어지럽히지 않고 냄새를 맡을 수 있게 합니다. 네 개의 필드가 더 기하학을 결정합니다: 0x1C의 바이트 순서는 0xFFFE여야 하며, 이는 값싼 두 번째 시그니처 검사 역할도 겸합니다. 0x1E의 섹터 시프트는 섹터 크기를 1 shl SectorShift로 주므로, 버전 3은 시프트 9로 512바이트 섹터를, 버전 4는 시프트 12로 4096바이트 섹터를 사용합니다. 0x20의 미니 섹터 시프트는 6이며, 미니 섹터를 64바이트로 만듭니다. 그리고 0x38의 미니 스트림 컷오프는 4096입니다. 이어지는 주소 산술은 가장 흔하게 틀리는 부분입니다. 섹터 0은 헤더 바로 뒤에서 시작하므로, 섹터 N은 바이트 오프셋 512 + N * SectorSize에서 시작합니다 — 여기서 리터럴 512에 주목하십시오, SectorSize가 아닙니다. 버전 3 파일에서는 둘이 같아서 버그가 영원히 숨습니다. 버전 4 파일에서는 조용히 잘못된 섹터를 읽으며, 그래서 HotXLS는 이것을 SidToOffset이라는 함수 하나에 몰아넣습니다

복합 파일은 파일 안의 FAT 파일 시스템이므로, 그것을 읽는다는 것은 FAT[n]이 섹터 n 다음에 오는 ID를 담고 있는 섹터 ID들의 연결 목록을 순회한다는 뜻입니다. 세 개의 센티널이 체인을 종료하거나 주석을 답니다 — ENDOFCHAIN, FAT 자체에 속한 섹터를 위한 FATSECT, DIFAT 섹터를 위한 DIFSECT — 그리고 셋 다 음수인 부호 있는 32비트 정수로 읽히므로 루프 조건이 단순해집니다. FAT를 찾으려면 한 단계 더 간접 참조가 필요합니다: DIFAT는 FAT 섹터가 어디에 사는지 말해주는 섹터 ID 배열이며, 그 처음 109개 항목은 오프셋 0x4C의 헤더 안에 있습니다. TlxCompoundFile은 그 109개를 순회하고 첫 음수 항목에서 멈춘 다음 각 FAT 섹터를 하나의 평평한 Integer 배열로 이어붙입니다. 512바이트 섹터에서 섹터당 128개 항목씩 109개 FAT 섹터이므로, 13,952개의 주소 지정 가능한 섹터, 즉 대략 6.8 MiB의 컨테이너가 DIFAT가 자체 체인으로 넘쳐야 하기 전까지 가능합니다

두 번째 할당 테이블이 존재하는 이유는 512바이트 섹터가 작은 스트림에는 대부분 공간을 낭비하기 때문입니다. 4096바이트 컷오프 아래의 모든 스트림은 섹터에 저장되지 않습니다: 그것은 미니 스트림 안에 살며, 미니 스트림 자체는 루트 디렉터리 항목에 매달린 평범한 스트림으로, 64바이트 미니 섹터로 세분되고 헤더 오프셋 0x3C에 뿌리를 둔 병렬 MiniFAT를 통해 체인됩니다. 실제 .xls를 열어보면 Workbook 스트림은 일반 FAT 위에 있고 요약 정보 스트림은 미니 섹터 공간 아래에 있습니다. 그래서 FAT 경로만 다루는 구현은 문서 메타데이터가 필요해지기 전까지는 제대로 작동하는 것처럼 보입니다. 디렉터리는 세 번째 구조이며 컨테이너를 탐색 가능하게 만드는 구조입니다: 각 항목은 정확히 128바이트, 512바이트 섹터당 네 개이며, 처음 64바이트에 UTF-16 이름을, 0x40에 그 바이트 길이를, 0x42에 객체 유형(1 = 저장소, 2 = 스트림, 5 = 루트)을, 0x44, 0x48, 0x4C에 트리 링크를, 0x74에 시작 섹터를, 0x78에 32비트 스트림 크기를 담습니다. 그 이름 길이는 종료 널을 포함한 바이트를 세므로, 문자 개수는 NameLen div 2 - 1이며, 이것을 하나 어긋나게 계산하면 Workboo라는 이름의 스트림을 얻게 됩니다

메모리 버퍼에서 Workbook 스트림 꺼내기

TlxCompoundFile.OpenStream은 위의 모든 것을 스트림 이름을 받아 완전히 구체화된 바이트를 담은 TlxCfbStream을 반환하는 단일 호출 뒤에 숨깁니다. 전체 시퀀스 — 냄새 맡기, 로드, 추출 — 는 TBytesStream에 대해 실행되며 어느 것도 디스크를 건드리지 않습니다

uses
  Classes, SysUtils, lxCompoundFile;

function ExtractBiffPayload(const Blob: TBytes): TBytes;
var
  Src: TBytesStream;
  Cfb: TlxCompoundFile;
  Wb: TlxCfbStream;
begin
  SetLength(Result, 0);
  Src:= TBytesStream.Create(Blob);
  try
    if not lxIsCompoundStream(Src) then
      Exit;                            // not a CFB container at all
    Cfb:= TlxCompoundFile.Create;
    try
      Cfb.LoadFromStream(Src);         // header, FAT, directory, MiniFAT
      Wb:= Cfb.OpenStream('Workbook'); // BIFF8
      if Wb = nil then
        Wb:= Cfb.OpenStream('Book');   // BIFF5 / BIFF7
      if Wb <> nil then
      try
        Result:= Wb.Data;
      finally
        Wb.Free;
      end;
    finally
      Cfb.Free;
    end;
  finally
    Src.Free;
  end;
end;

여기서 두 가지 세부 사항은 짚어볼 가치가 있습니다. LoadFromStream은 기본값이 FalseAOwnsStream 플래그를 받으므로, 호출자가 소스 스트림에 대한 책임을 계속 갖습니다 — 이는 의도적입니다. 흔한 경우가 애플리케이션이 이미 소유한 스트림이기 때문입니다. 그리고 OpenStreamData, Size, Read, Seek, CopyTo를 통해 노출되는 자신의 바이트 사본을 소유한 TlxCfbStream을 반환합니다. 그 복사는 큰 워크북에서는 실제 비용이며, 반환된 객체가 컨테이너가 해제된 뒤에도 유효하게 남는 설계의 정직한 대가입니다. 워크북이 전체를 메모리에 복사하는 것이 애초에 잘못된 형태일 만큼 클 때는, 대용량 스프레드시트를 위한 스트리밍 다이렉트 리더가 더 나은 진입점입니다

암호화된 XLSX가 XLS 파일처럼 보이는 이유는 무엇인가

컨테이너 수준에서는 실제로 그것이기 때문입니다 — 그리고 이것이 그 계층을 소유하는 실질적인 보상입니다. 암호화된 .xlsx를 헥스 에디터로 열어보면 처음 여덟 바이트는 D0 CF 11 E0 A1 B1 1A E1로, 1997년식 .xls와 바이트 단위로 동일합니다. [MS-OFFCRYPTO] 암호화는 ZIP 패키지를 제자리에서 암호화하지 않기 때문입니다: 전체 패키지를 EncryptedPackage라는 이름의 스트림으로 CFB 컨테이너 안에 감싸며, 그 옆에는 알고리즘을 설명하는 EncryptionInfo 스트림이 있습니다. 따라서 시그니처는 컨테이너를 식별할 뿐 페이로드에 대해서는 아무것도 말하지 않습니다. BIFF 워크북과 암호화된 OOXML 패키지를 구별하려면 디렉터리를 읽어야 합니다. LoadFromStream 이후에는 EntryCountEntries에 대한 스캔이거나 한 쌍의 HasStream 프로브입니다

type
  TCfbPayload = (cpUnknown, cpBiffWorkbook, cpEncryptedOoxml);

function ClassifyContainer(AStream: TStream): TCfbPayload;
var
  Cfb: TlxCompoundFile;
  E: TlxCfbEntry;
  I: Integer;
begin
  Result:= cpUnknown;
  Cfb:= TlxCompoundFile.Create;
  try
    Cfb.LoadFromStream(AStream);
    for I:= 0 to Cfb.EntryCount - 1 do
    begin
      E:= Cfb.Entries(I);
      if E.EntryType <> cfbStream then
        Continue;
      if E.Name = 'EncryptedPackage' then
        Result:= cpEncryptedOoxml
      else if (E.Name = 'Workbook') or (E.Name = 'Book') then
        Result:= cpBiffWorkbook;
    end;
  finally
    Cfb.Free;
  end;
end;

디렉터리 이름에는 그 자체로 경고가 필요합니다: 요약 정보 스트림은 이름 앞에 선행하는 0x05 제어 문자를 갖고 있으므로, 일반 표시 문자열에 대한 비교는 결코 그것과 일치하지 않으며, 순진한 로그 라인은 그것을 쓰레기로 렌더링합니다. 이 분류 이후의 모든 것 — 키 유도, 비밀번호 검증자 확인 — 은 별개의 문제이며, Excel이 잘못된 암호 모드로 암호화된 워크북을 거부하는 이유에 관한 노트에서 다룹니다. 컨테이너 계층은 여러분이 어느 문 앞에 서 있는지만 알려줄 뿐입니다

Excel이 실제로 열 수 있는 컨테이너 쓰기

TlxCompoundFile의 쓰기 쪽은 읽기 쪽보다 의도적으로 더 좁으며, 그 이유를 이해하면 스펙과의 논쟁을 피할 수 있습니다. [MS-CFB]는 방대한 유효 컨테이너 공간을 허용합니다: 다단계 저장소, 제대로 균형 잡힌 레드-블랙 디렉터리 트리, 미니 스트림, DIFAT 체인. Excel은 그 공간의 작은 모서리를 방출하고 다소 더 큰 부분을 읽습니다. HotXLS는 그보다 더 작은 모서리를 씁니다 — Excel이 명백히 로드하는 최소한입니다. 모든 스트림은 미니 스트림 경로 없이 일반 FAT 위에 놓입니다. 디스크 공간을 대가로 정확성을 삽니다: Excel이라면 다섯 개의 64바이트 미니 섹터로 압축했을 300바이트짜리 요약 스트림이 대신 512바이트 섹터 전체를 차지하며, 워크북에게 이는 두 번째 할당 테이블, 두 번째 체인 순회, 쓰기 경로에서 그것을 뒷받침하는 루트 항목 스트림을 유지하는 것에 비하면 잡음일 뿐입니다. 디렉터리 항목은 루트 아래 평평한 형제 체인을 이루며 모든 노드가 검정으로 색칠되고, 방출 순서는 고정되어 있습니다: 헤더 자리표시자, 스트림 데이터 섹터, 디렉터리 섹터, FAT 섹터, 그런 다음 끝에서야 알 수 있는 섹터 ID로 헤더를 다시 쓰기 위해 되돌아가는 탐색. FAT는 짧은 고정소수점 루프를 통해 스스로 크기를 정합니다. FAT 섹터를 추가하는 것 자체가 섹터 개수를 또 다른 FAT 섹터가 필요할 만큼 높일 수 있기 때문입니다

procedure SaveAsCompoundFile(const Dest: string; const BiffBytes: TBytes);
var
  FS: TFileStream;
  Cfb: TlxCompoundFile;
begin
  FS:= TFileStream.Create(Dest, fmCreate);
  try
    Cfb:= TlxCompoundFile.Create;
    try
      Cfb.CreateNew(FS);                  // v3 header, 512-byte sectors
      Cfb.AddStream('Workbook', BiffBytes);
      Cfb.Save;                           // data -> dir -> FAT -> header
    finally
      Cfb.Free;
    end;
  finally
    FS.Free;
  end;
end;

구현이 멈추는 지점

세 가지 경계는 분명히 밝혀둘 가치가 있습니다. 예외 지경계를 조용히 잘못 처리하는 컨테이너 리더는 예외를 던지는 리더보다 나쁘기 때문입니다. TlxCompoundFile은 헤더에 상주하는 109개의 DIFAT 항목을 읽으며 그 너머로 0x44의 DIFAT 체인을 따라가지 않습니다. 이는 읽을 수 있는 컨테이너를 512바이트 섹터에서 대략 6.8 MiB로 제한합니다 — HotXLS가 실전에서 만나는 실제 .xls 파일보다는 여유 있게 크지만, 그럼에도 확고한 상한선이며, 작성기도 자신이 기술할 수 없는 컨테이너를 방출하는 대신 같은 한계를 명시적으로 강제합니다. 둘째, 4096바이트 섹터를 가진 버전 4 컨테이너는 섹터 크기 산술로 수용되지만 코드가 그것에 맞춰 튜닝되어 있지는 않으며, 64비트 스트림 크기는 참조되지 않습니다: HotXLS는 오프셋 0x78의 하위 32비트만 읽고 상위 절반은 손대지 않는데, 이는 버전 3에서만 올바르고 오직 버전 3에서만 그렇습니다. 셋째, 항목 조회는 부모 저장소에서 레드-블랙 트리를 내려가는 순회가 아니라 디렉터리 목록 전체에 대한 이름 기반의 평평한 스캔입니다. 그래서 중첩된 저장소는 경로가 아니라 이름 충돌로 해석됩니다 — .xls 파일이 필요로 하는 모든 스트림은 최상위 레벨에 있으며, 이것이 더 단순한 설계를 정당화하는 이유이지만, SomeStorage/SomeStream을 주소로 지정하려는 코드는 그것을 찾지 못할 것입니다

그 무엇도 이 유닛의 존재 목적을 바꾸지 않습니다. 컨테이너 계층을 소유한다는 것은 .xls 처리를 평범한 Object Pascal로 바꾼다는 뜻입니다: 바이트 배열로부터 파싱 가능하고, 파일 시스템 없이 테스트 가능하며, 컴파일러가 대상으로 하는 어떤 플랫폼으로든 이식 가능하고, COM 아파트먼트에서 자유롭습니다. 또한 냄새 맡기 지름길을 퇴역시킵니다. 워크북을 식별한다는 것은 이제 처음 여덟 바이트가 아니라 디렉터리를 읽는다는 뜻이기 때문입니다 — 전체 워크북을 열지 않고 시트 이름 나열하기 뒤에 있는 것과 같은 규율입니다

TlxCompoundFile은 델파이와 C++Builder용 HotXLS Excel Component의 일부로, 그 위에 놓인 BIFF 및 OOXML 계층과 함께 제공됩니다. 제품 페이지에는 전체 유닛 레퍼런스와 지원되는 컴파일러 매트릭스가 있습니다