Technical Article

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

Microsoft Word나 Excel이 생성한 PDF를 열어서 페이지를 넘겨보면 이상한 점이 전혀 보이지 않습니다. 이를 Delphi 프로그램에 로드하여 페이지 수를 읽어보면 올바른 값이 반환됩니다. 하지만 암호화를 활성화한 상태에서 다시 저장하려고 하면 EListError가 발생하며 실패하거나, 손상된 상호 참조(cross-reference) 경고와 함께 출력 파일이 열립니다. 파일이 손상된 적은 없었습니다. 이는 하이브리드 참조 파일이며, 15년 된 뷰어에서도 열리도록 해주는 바로 그 구조가 읽기를 너무 일찍 중단하는 로더를 실패하게 만드는 구조입니다.

이것은 내부의 모든 테스트를 통과한 PDF 파이프라인이 정상적으로 왕복 변환(round-trip)할 수 없는 파일을 만나게 되는 가장 일반적인 방법 중 하나입니다. 입력 파일이 모두 사내에서 생성되었으므로 하이브리드 형식이었던 적이 없었기 때문입니다. 첫 번째 하이브리드 파일은 고객이 스프레드시트에서 내보낸 청구서를 전달하는 날 전달됩니다.

Word와 Excel이 실제로 기록하는 것

ISO 32000-1의 §7.5.8.4에서는 하이브리드 참조 레이아웃을 설명합니다. 개체 스트림(object stream)과 같은 PDF 1.5 기능을 원하면서도 PDF 1.4 리더가 파일을 열 수 있도록 하려는 애플리케이션은 상호 참조 정보를 두 번 기록합니다. 버전 1.4까지의 모든 PDF 마지막에 있던 고정 너비 ASCII 행인 기존의 상호 참조 테이블과, 나머지 부분을 인덱싱하는 상호 참조 스트림이 있습니다. 기존 섹션의 트레일러(trailer)에는 해당 스트림의 바이트 오프셋 값을 가진 /XRefStm 항목이 포함되어 있습니다.

역할 분담은 의도된 것입니다. 이전 리더가 도달해야 하는 카탈로그 및 페이지 트리 등의 개체는 기존 테이블에서 주소 지정이 가능합니다. 압축된 개체 스트림으로 유입된 개체는 기존 테이블에서 free 상태(f 유형 항목)로 표시되므로, 1.4 리더는 이를 무시하고 지나쳐 해석할 수 없는 구조에서 멈추지 않습니다. 이들의 실제 위치는 오직 상호 참조 스트림에만 존재합니다. 이러한 파일의 고유 식별자는 끝부분(tail)입니다. 즉, 흔히 xref 뒤에 0 0 하위 섹션 헤더가 붙어 있는 짧은 기존 섹션이며, 해당 섹션의 트레일러가 실제 복구 데이터가 위치한 /XRefStm을 가리키고 있습니다.

올바른 페이지 수가 아무것도 보장하지 못하는 이유

카탈로그와 페이지 트리가 기존 테이블에서 도달할 수 있도록 설계되어 있기 때문에, 기존 테이블만 읽는 로더도 /Root를 찾고 페이지 트리를 탐색하여 올바른 페이지 수를 반환합니다. 이전 리더에 필요한 모든 것이 존재하므로 파일이 정상인 것처럼 보입니다. 누락되는 개체는 개체 스트림에 포함되어 압축된 것들입니다. 예를 들어 AcroForm 필드 사전, 태그가 지정된 PDF 구조 요소, 레거시 뷰어에 표시될 필요가 없었던 작은 사전들의 긴 목록 등입니다.

무언가가 이러한 개체를 터치할 때까지는 그 간격을 알아차릴 수 없으며, 완전히 다시 저장할 때 비로소 모든 개체를 건드리게 됩니다. 문서 전체를 탐색하여 암호화하거나 다시 작성하는 작업이 정확히 모든 개체 번호를 순서대로 요청하는 작업이기 때문에, 근본적인 원인에서 한참 떨어진 로드 시점이 아닌 저장 시점에 증상이 나타나게 됩니다.

함정은 xref를 보고 멈추는 탐지기입니다

파일이 어떻게 인덱싱되는지 판단하는 저렴한 방법은 startxref를 따라가서 그것이 가리키는 첫 바이트를 검사하는 것입니다. xref라는 키워드는 기존 테이블을 의미하고, 스트림 개체는 상호 참조 스트림을 의미합니다. 이 테스트는 한 가지 방식만 사용하는 파일에는 정확합니다. 하지만 하이브리드 파일의 경우에는 올바르지 않습니다. 하이브리드 파일의 startxref는 이전 리더를 만족시키기 위한 단 하나의 목적으로 기존 섹션을 가리키며, 실제 대부분의 문서는 해당 섹션의 트레일러에 있는 /XRefStm에 인덱싱되어 있기 때문입니다. 만난 첫 번째 xref에 대해 "classic"을 반환하는 탐지기는 /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;

이러한 조기 종료 탐지기가 있는 경우 로드는 정상적으로 보이고, 다시 저장할 때 비로소 존재하지 않는 개체들이 그 모습을 드러냅니다. 해결 방법은 처음에 더 많은 바이트를 읽는 것이 아니라, 하이브리드 트레일러를 인식하고 파일 처리가 완료되었다고 판단하기 전에 /XRefStm을 따르는 것입니다.

병합 순서는 타협의 대상이 아닙니다

두 인덱스를 모두 읽었으면 한 방향으로만 병합할 수 있습니다. 상호 참조 스트림이 먼저 병합되어야 하고, 그 주변에 기존 항목들이 채워져야 합니다. 그 이유는 형식이 가진 미세한 속임수 때문입니다. 하이브리드 파일은 이전 리더들이 무시할 수 있도록 압축된 개체를 기존 테이블에서 free 상태로 표시합니다. 최초로 발견된 항목을 우선하는(first-seen-wins) 정책을 따르고 기존 테이블을 먼저 읽는 로더는 이러한 개체 번호를 free 상태로 기록하고, 해당 슬롯이 이미 채워졌기 때문에 실제 위치를 나타내는 스트림 항목을 폐기하게 됩니다. 순서를 반대로 하면 개체 스트림 번호와 인덱스로 구성된 스트림의 type 2 항목들이 자신이 소유해야 하는 슬롯을 차지하고, 기존 항목들이 그 주위에 채워지게 됩니다.

동일한 규칙이 이전 리비전이 삭제된 개체를 부활시키는 것을 방지합니다. 증분 업데이트는 /Prev를 통해 역방향으로 체인 연결되며, type 0 free 항목은 최신 섹션이 개체 번호를 폐기했음을 나타내는 센티널(sentinel) 역할을 합니다. 체인의 뒷부분에 있는 이전 섹션이 유효하지 않은 위치로 이 센티널을 덮어쓰도록 허용해서는 안 됩니다. free 마커에 대해 최초 발견 값을 신뢰할 수 있는 것으로 간주하면 삭제된 개체는 삭제된 상태로 유지됩니다. 그렇지 않고 부주의하게 처리하면 파일 자체의 기록이 최신 리비전에서 제거한 콘텐츠를 복원하게 됩니다.

HotPDF에서 이것이 의미하는 바

엔진은 상호 참조 데이터를 구문 분석해야 하는 모든 경로에서 하이브리드 참조 파일을 대신 해석합니다. LoadFromFile 또는 LoadFromStream을 사용하여 문서를 로드하고, 변경한 뒤 SaveLoadedDocument를 호출하십시오. 아니면 입력을 읽고 출력을 쓰는 EncryptFile과 같은 일회성 작업을 실행할 수도 있습니다. 어느 쪽이든 복구 프로세스는 /XRefStm을 읽고 기존 항목보다 앞서 스트림 섹션을 병합하며, 쓰기 작업이 개체들을 열거하기 전에 스트림에 상주하는 개체들을 해석합니다. 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의 업스트림에 위치합니다. Word, Excel, PowerPoint 및 수많은 "PDF로 저장" 파이프라인에서 오는 파일은 기본적으로 하이브리드 형식입니다. 따라서 자체 작성기 출력을 대상으로만 작동을 확인하는 로더는 테스트 과정에서 하이브리드 형식을 전혀 만나지 못할 수 있습니다. 자체 코드로 생성한 파일뿐만 아니라 실제 Office 애플리케이션에서 내보낸 문서를 테스트 픽스처(fixture)로 구성하여 활용해 보십시오.

의심스러운 파일 확인하기

두 가지 검사만으로 이를 빠르게 판단할 수 있습니다. 16진수 보기(hex view)로 파일을 열고 마지막 startxref 뒤의 바이트를 읽어봅니다. 하이브리드 파일은 트레일러 사전에 /XRefStm이 포함된 짧은 기존 섹션을 보여줍니다. 아니면 전체 구문 분석에서 보고된 개체 수와 트레일러의 /Size가 선언하는 가장 높은 개체 번호를 비교하십시오. 큰 격차가 존재한다면 로더가 열지 않은 스트림에 개체들이 숨겨져 있음을 의미하며, 이는 나중에 저장할 때 실패로 이어지는 동일한 결함입니다.

개체 스트림 및 압축된 상호 참조가 애초에 생성되는 방식인 작성자 측면에 대한 이야기는 개체 스트림 및 증분 업데이트에 관한 아티클에서 다룹니다. 대상 하이브리드 파일이 매우 큰 경우, 대규모 PDF 워크플로를 위한 Direct File API 가이드의 로딩 기법을 통해 파일 전체를 메모리에 로드하지 않고도 검사할 수 있습니다. 두 기법 모두 본 블로그의 다른 곳에서 다루는 로드, 편집, 암호화 및 서명 API와 함께 Delphi 및 C++Builder용 HotPDF Component의 일부로 제공되는 복구 프로세스와 완벽하게 부합합니다.