기술 문서

Delphi에서 Word 및 Excel의 하이브리드 참조 PDF 로드하기

Microsoft Word나 Excel에서 생성한 PDF를 열어 페이지를 훑어보면 아무런 이상이 없어 보입니다. Delphi 프로그램에 로드하여 페이지 수를 다시 읽어봐도 숫자는 정확합니다. 하지만 암호화를 켜고 다시 저장하면 작업이 EListError와 함께 실패하거나, 출력 파일에서 손상된 상호 참조(cross-reference) 경고가 열립니다. 파일이 손상된 적은 없습니다. 이는 하이브리드 참조 파일(hybrid-reference file)이며, 15년 된 구형 뷰어에서 파일을 열 수 있게 해주는 바로 그 구조가 읽기를 너무 일찍 멈추는 로더(loader)를 실패하게 만드는 원인입니다

이것은 모든 내부 테스트를 통과한 PDF 파이프라인이 양방향 처리(round-trip)할 수 없는 파일을 만나는 가장 흔한 방식 중 하나입니다. 입력 파일들이 모두 자체적으로 생성되었기 때문에 하이브리드 형식이 아니었던 것입니다. 최초의 하이브리드 파일은 고객이 스프레드시트에서 내보낸 인보이스를 전달하는 그날 도착하게 됩니다

Word와 Excel이 실제로 작성하는 내용

ISO 32000-1은 §7.5.8.4에서 하이브리드 참조 레이아웃을 설명합니다. 개체 스트림(object streams)과 같은 PDF 1.5 기능을 원하면서도 PDF 1.4 리더(reader)가 파일을 열 수 있게 하려는 애플리케이션은 상호 참조 정보를 두 번 씁니다. 버전 1.4까지 모든 PDF의 끝에 위치했던 고정 폭 ASCII 행인 클래식 상호 참조 테이블(classic cross-reference table)이 있고, 나머지를 인덱싱하는 상호 참조 스트림(cross-reference stream)이 존재합니다. 클래식 섹션의 트레일러(trailer)에는 해당 스트림의 바이트 오프셋을 값으로 갖는 /XRefStm 항목이 포함되어 있습니다

이러한 역할 분담은 의도된 것입니다. 이전 버전의 리더가 접근해야 하는 카탈로그 및 페이지 트리 등의 개체는 클래식 테이블에서 주소 지정이 가능합니다. 압축된 개체 스트림으로 접힌(folded) 개체들은 클래식 테이블에서 f 유형 항목과 함께 사용 가능(free)으로 표시되므로 1.4 리더는 이를 그냥 지나치고 분석할 수 없는 구조에서 오류를 일으키지 않습니다. 이들의 실제 위치는 오직 상호 참조 스트림에만 존재합니다. 이러한 파일의 특징은 꼬리(tail) 부분에 있습니다. 짧은 클래식 섹션이 있으며, 흔히 xref 뒤에 0 0 하위 섹션(subsection) 헤더가 오는 형태에 불과하고 그 트레일러는 실제 복구 데이터가 위치한 /XRefStm을 가리킵니다

정확한 페이지 수가 아무것도 증명하지 못하는 이유

카탈로그와 페이지 트리는 의도적으로 클래식 테이블에서 접근할 수 있도록 되어 있기 때문에, 해당 테이블만 읽는 로더는 /Root를 찾아 페이지 트리를 순회하고 정확한 페이지 수를 보고합니다. 이전 버전의 리더가 필요로 하는 모든 것이 존재하므로 파일은 정상인 것처럼 보입니다. 누락된 개체들은 개체 스트림에 포장된 것들입니다. AcroForm 필드 사전(dictionaries), 태그가 지정된 PDF(tagged-PDF) 구조 요소, 그리고 레거시 뷰어에 표시될 필요가 없었던 수많은 작은 사전들이 여기에 해당합니다

무언가가 그 개체들을 건드리기 전까지는 간극(gap)을 알아차리지 못하며 전체를 다시 저장하는(resave) 과정에서는 모든 개체를 건드리게 됩니다. 문서를 다시 암호화하거나 다시 작성하기 위해 순회(walking)하는 것은 모든 개체 번호를 순서대로 요청하는 작업이므로, 증상이 원인과 멀리 떨어진 로드(load) 시점이 아니라 저장 시점에 나타나는 것입니다

xref를 보고 멈추는 탐지기의 함정

파일이 어떻게 인덱싱되는지 결정하는 쉬운(cheap) 방법은 startxref를 따라가서 가리키는 첫 번째 바이트를 검사하는 것입니다. xref 키워드는 클래식 테이블을 의미하고 스트림 개체는 상호 참조 스트림을 의미합니다. 이 테스트는 한 가지 방식을 고수하는 모든 파일에 대해 정확합니다. 하지만 하이브리드 파일의 경우에는 틀립니다. 하이브리드 파일의 startxref는 오직 이전 버전의 리더를 만족시키기 위한 목적으로 클래식 섹션을 겨냥하는 반면, 문서의 대부분이 실제로 인덱싱되는 곳은 해당 섹션의 트레일러에 있는 /XRefStm입니다. 만나는 첫 번째 xref에서 "클래식"을 반환하는 탐지기는 /XRefStm을 절대 읽지 않으며, 스트림에만 존재하는 모든 개체는 보이지 않게 됩니다

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('Invoice_XLS.pdf');  // count is correct
    // inspect or edit the loaded document here
    Pdf.SaveLoadedDocument('Invoice_secured.pdf');     // walks every object
  finally
    Pdf.Free;
  end;
end;

조기 종료(early-exit) 탐지기가 설치된 상태에서는 로드가 문제없어 보이지만, 다시 저장할 때 부재하는 개체들이 자신을 드러냅니다. 해결책은 처음부터 더 많은 바이트를 읽는 것이 아닙니다. 하이브리드 트레일러를 인식하고 /XRefStm을 따라간 후 파일 처리가 완료되었다고 판단하는 것입니다

병합 순서는 타협할 수 없습니다

두 인덱스를 모두 읽고 나면 한 방향으로만 결합될 수 있습니다. 상호 참조 스트림을 먼저 병합하고 그 주변을 클래식 항목으로 채워야 합니다. 그 이유는 포맷의 핵심에 있는 작은 속임수 때문입니다. 하이브리드 파일은 압축된 개체들을 이전 버전의 리더가 무시하도록 클래식 테이블에 사용 가능(free)으로 표시합니다. 처음 본 것이 우선순위를 갖는(first-seen-wins) 정책을 존중하여 클래식 테이블을 먼저 읽는 로더는 해당 개체 번호를 사용 가능으로 기록한 다음, 슬롯이 이미 차지되어 있기 때문에 이들의 실제 위치를 가리키는 스트림 항목을 삭제하게 됩니다. 순서를 뒤집으면 스트림의 유형(type) 2 항목(각각 개체-스트림 번호와 인덱스로 구성됨)들이 자신들이 가져야 할 슬롯을 차지하고, 그 주위에 클래식 항목들이 자리 잡게 됩니다

동일한 원칙이 삭제된 개체를 이전 리비전(revision)이 부활시키는 것을 방지해줍니다. 증분 업데이트(Incremental updates)는 /Prev를 통해 역방향으로 연결되며, 유형(type) 0 자유 항목(free entry)은 더 최근의 섹션이 개체 번호를 폐기했음을 알리는 감시자(sentinel) 역할을 합니다. 체인상에서 더 나중의, 더 오래된 섹션이 오래된(stale) 위치로 해당 감시자를 덮어쓰도록 허용해서는 안 됩니다. 자유 마커(free markers)에 대해 가장 먼저 확인된 것(first-seen)을 권위있는 것으로 취급하면 삭제된 개체는 삭제된 상태로 유지됩니다. 하지만 부주의하게 다루면 파일 자체의 기록이 최신 리비전에서 제거한 콘텐츠를 다시 되살려내게 됩니다

이것이 HotPDF에서 의미하는 바

엔진이 하이브리드 참조 파일을 알아서 해결(resolve)해 주며, 상호 참조 데이터를 분석해야 하는 모든 경로(path)에서 이를 수행합니다. LoadFromFile이나 LoadFromStream으로 문서를 로드하고 변경한 후 SaveLoadedDocument를 호출하거나, 입력을 읽어 출력을 작성하는 EncryptFile과 같은 원샷(one-shot) 작업을 실행해 보세요. 어느 쪽이든 복구 프로세스는 /XRefStm을 읽고, 클래식 항목보다 앞서 스트림 섹션을 병합하며, 작성(write) 프로세스가 이들을 나열하기 전에 스트림에 존재하는 개체들을 해결(resolve)합니다. AES-256 암호화 경로는 이 문제가 처음으로 나타났던 지점인데, 문서를 암호화하려면 모든 개체를 다시 작성해야 하고 따라서 모든 개체의 위치가 미리 파악되어 있어야 하기 때문입니다

// One-shot: read the hybrid input, write an AES-256 encrypted copy
Pdf.EncryptFile('Letter_DOC.pdf', 'Letter_secured.pdf',
  'owner-secret', '', aes256, [prPrint, prFillAnnotations]);

알아둘 만한 세부 사항은 API의 상위 단계(upstream)에 있습니다. Word, Excel, PowerPoint 그리고 수많은 "PDF로 저장" 파이프라인에서 생성된 파일들은 일상적으로 하이브리드 형식을 띱니다. 따라서 사용자가 직접 만든 생성기의 출력물로만 로더를 테스트하면 이러한 파일을 한 번도 만나지 못할 수 있습니다. 자체 코드에서 생성한 파일뿐만 아니라 실제 Office 애플리케이션에서 내보낸(exported) 문서들을 테스트 픽스처(fixtures)에 포함시키십시오

의심되는 파일 확인하기

두 번의 검사로 이 의문을 빠르게 해결할 수 있습니다. 헥스(hex) 뷰로 파일을 열고 마지막 startxref 뒤의 바이트를 읽어보세요. 하이브리드 파일은 트레일러 사전에 /XRefStm이 포함된 짧은 클래식 섹션을 보여줍니다. 또는 전체 분석(parse) 시 보고되는 개체의 수와 트레일러의 /Size가 선언하는 가장 높은 개체 번호를 비교해보세요. 큰 차이가 난다면 로더가 열지 않은 스트림에 개체들이 숨겨져 있다는 뜻이며, 이 누락된 부분은 나중에 저장 시점의 실패로 이어지게 됩니다

일반적인 Excel 내보내기 파일의 꼬리(tail) 부분을 살펴보면 첫 번째 검사를 구체적으로 이해할 수 있습니다. 마지막 xref 키워드 뒤의 모든 것은 일반 ASCII이므로 헥스 뷰에서 바로 그 특징을 읽을 수 있습니다. (오프셋은 예시이며 주석이 추가됨)

xref
0 0                          % empty classic subsection: no rows at all
trailer
<< /Size 216                 % one past the highest object number in use
   /Root 1 0 R
   /Info 15 0 R
   /ID [<5C9A...> <5C9A...>]
   /XRefStm 87325            % byte offset of the cross-reference stream
>>
startxref
88710                        % points at the classic section above
%%EOF

0 0 하위 섹션(subsection)이 증거입니다. 항목이 0개인 클래식 테이블은 오로지 트레일러를 전달하기 위해 존재하며, 이 트레일러는 주로 /XRefStm 87325를 알리기 위해 존재합니다. xref 키워드에서 멈추는 탐지기는 이 시점에서 아무것도 없는 인덱스를 보게 됩니다. 육안으로 확인하는 것보다 스크립트로 확인하고 싶다면, 해당 마커(marker)는 항상 파일의 마지막 몇 킬로바이트(kilobytes) 내에 위치하므로 제한된 범위를 역방향으로 읽는 것만으로도 충분합니다

// Returns the /XRefStm offset from the file's tail, or -1 if the
// marker is absent (the file is not hybrid, or not a PDF at all)
function FindXRefStm(const FileName: string): Int64;
var
  FS: TFileStream;
  Tail: AnsiString;
  Len, P: Integer;
begin
  Result := -1;
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    Len := 2048;                        // the trailer lives in the tail
    if FS.Size < Len then
      Len := Integer(FS.Size);
    FS.Position := FS.Size - Len;       // bounded backward read: 2 KB max
    SetLength(Tail, Len);
    FS.ReadBuffer(Tail[1], Len);
  finally
    FS.Free;
  end;
  P := Pos(AnsiString('/XRefStm'), Tail);
  if P = 0 then
    Exit;                               // no hybrid marker in the tail
  Inc(P, Length('/XRefStm'));
  while (P <= Len) and (Tail[P] in [' ', #9, #13, #10]) do
    Inc(P);                             // skip whitespace after the key
  Result := 0;
  while (P <= Len) and (Tail[P] in ['0'..'9']) do
  begin
    Result := Result * 10 + Ord(Tail[P]) - Ord('0');
    Inc(P);
  end;
end;

// Usage: a non-negative result names the byte where the stream starts
if FindXRefStm('Invoice_XLS.pdf') >= 0 then
  Writeln('hybrid-reference file: resave will need the /XRefStm section');

이 탐지(probe) 작업은 파서(parser)가 아닌 분류 작업(triage)으로 취급하세요. 이는 오직 다시 저장(resave) 작업을 실행하기 전 일괄 처리 중 어떤 파일에 주의를 기울여야 하는지만 알려줄 뿐 그 이상은 아닙니다. 로더가 찾아낸 오프셋을 바탕으로 섹션 체인을 따라가며 스트림 항목을 클래식 항목보다 먼저 병합하고 자유 항목(free-entry) 감시자(sentinel)를 존중하면서 수행해야 하는 구체적인 단계는 Office 애플리케이션의 하이브리드 참조 PDF 처리와 관련된 후속 기사에서 단계별로 살펴볼 수 있습니다

이 이야기의 작성(writer) 측면, 즉 처음에 개체 스트림(object streams)과 압축된 상호 참조가 어떻게 생성되는지에 대한 내용은 개체 스트림 및 증분 업데이트(incremental updates)에 관한 기사에서 다룹니다. 문제가 되는 하이브리드 파일의 용량이 매우 클 경우, 대규모 PDF 워크플로우를 위한 Direct File API 가이드에 나온 로딩 기술을 사용하면 파일 전체를 메모리로 읽어 들이지 않고도 파일을 검사할 수 있습니다. 두 가지 기술 모두 이 블로그의 다른 곳에서 다룬 로딩, 편집, 암호화 및 서명 API와 함께 Delphi 및 C++Builder용 HotPDF 컴포넌트의 일부로 제공되는 여기 설명된 복구 기능과 자연스럽게 결합됩니다