기술 문서

PDF 파일 구조: 헤더(Header), 본문(Body), Xref, 트레일러(Trailer)

PDF 리더(뷰어)는 파일의 시작부터 읽지 않습니다. 파일의 끝에서부터 시작합니다. 마지막 몇 바이트에 다른 모든 내용의 주소가 들어 있으며, 이 순서를 이해하지 못하는 파서는 첫 줄부터 포맷을 잘못 읽게 됩니다. 따라서 디스크 상의 PDF를 학습하는 가장 유용한 방법은 뷰어가 읽는 방식대로 배우는 것입니다. 꼬리부터 먼저 파악하고 뒤로 건너뛰어 맵(map)으로 이동한 다음 맵이 가리키는 객체를 확인하는 것입니다

압축된 데이터가 없다면 텍스트 편집기에서 바이트들을 있는 그대로 읽을 수 있을 만큼 매우 단순합니다. "Hello, World!"를 그리는 최소한의 1페이지 문서라면 500바이트 안에 들어가며, 포맷의 모든 구조적 요소가 다 보입니다. 다음은 4개의 파트가 표시된 파일 전체 내용입니다:

%PDF-1.0                          % 헤더(Header)
%âãÏÓ

1 0 obj                           % 본문(Body): 객체 시퀀스
<<
/Kids [2 0 R]
/Count 1
/Type /Pages
>>
endobj

2 0 obj
<<
/Rotate 0
/Parent 1 0 R
/Resources 3 0 R
/MediaBox [0 0 612 792]
/Contents [4 0 R]
/Type /Page
>>
endobj

3 0 obj
<< /Font << /F0 << /BaseFont /Times-Italic /Subtype /Type1 /Type /Font >> >> >>
endobj

4 0 obj
<< /Length 65 >>
stream
1. 0. 0. 1. 50. 700. cm BT
  /F0 36. Tf
  (Hello, World!) Tj
ET
endstream
endobj

5 0 obj
<< /Pages 1 0 R /Type /Catalog >>
endobj

xref                              % 상호 참조(Cross-reference) 테이블
0 6
0000000000 65535 f
0000000015 00000 n
0000000074 00000 n
0000000192 00000 n
0000000291 00000 n
0000000409 00000 n

trailer                           % 트레일러(Trailer)
<<
/Root 5 0 R
/Size 6
>>
startxref
459
%%EOF

4개의 파트는 항상 파일의 아래 방향으로 이 순서를 유지합니다: 헤더, 객체의 본문, 상호 참조 테이블, 그리고 트레일러입니다. 재미있는 점은 여러분이 이것들을 거의 역순으로 읽게 된다는 것입니다. ISO 32000-2 §7.5.1에서는 동일한 4개 부분의 구조를 제시하고 있으며, 뒤에서부터 읽는 이유는 순전히 실용적이기 때문입니다: 필요한 객체로 곧바로 점프하는 리더는 상단에서부터 모든 바이트를 스캔하는 뷰어보다 훨씬 빠르며, 이러한 무작위 액세스(random access)야말로 트레일러와 상호 참조 테이블이 존재하는 바로 그 목적입니다

헤더는 두 줄이며 두 번째 줄이 중요합니다

첫 번째 줄은 %PDF-1.0입니다. 구문상 퍼센트 기호는 주석으로 처리되지만, 리더는 이를 파일 서명으로 취급하고 여기서 버전 번호를 추출합니다. 실제 작업 시 버전을 느슨하게 다루는 편입니다. PDF 2.0용으로 빌드된 리더는 1.0이라고 명시된 파일도 문제없이 엽니다. 그리고 대부분의 뷰어는 선언된 버전이 잘못되었거나, 버전 라인이 바이트 제로(시작 지점)가 아니라 파일 안에 조금 파묻혀 있더라도 파일 열기를 시도합니다. 이 숫자는 어떤 기능을 기대해야 하는지에 대한 힌트일 뿐, 문을 잠그는 자물쇠(게이트)는 아닙니다

두 번째 줄은 사람들이 실수로 삭제하고 오후 내내 디버깅을 하느라 고생하는 줄입니다. 이 또한 주석이지만, 페이로드는 ASCII 127보다 큰 4바이트로 구성되어 있습니다. 이것이 존재하는 이유는 파일을 "텍스트 모드"로 전송하는 도구가 이를 바이너리로 인식하게 하여 줄 바꿈(line endings) 문자를 재작성하는 것을 막기 위함입니다. PDF에는 압축된 스트림이 포함되어 있는데, 이 스트림의 바이트가 우연히 캐리지 리턴(CR)이나 라인 피드(LF)와 일치할 수 있습니다. 전송 도구가 이것을 다시 작성하게 되면, 딕셔너리에 기록된 스트림 길이와 디스크 상의 바이트가 일치하지 않게 되어 파일이 손상됩니다. 하이-바이트(high-byte) 주석은 ASCII 모드 기반 FTP를 방어하기 위한 40년 된 방식이지만, 이를 통해 방지되는 오류가 조용하면서도 치명적이기 때문에 제대로 된 도구가 작성하는 모든 파일에 여전히 들어 있습니다

본문은 객체들을 가지며, 각각에는 번호가 매겨져 있습니다

문서를 구성하는 모든 것은 간접 객체(indirect objects)의 평면(flat) 시퀀스 형태로 본문에 존재합니다. 각 객체는 두 개의 정수와 obj 키워드로 열리고, 콘텐츠를 보유하며, endobj로 닫힙니다. 위 샘플의 1번 객체는 페이지 트리 노드입니다: 1 0 obj로 시작해서 딕셔너리가 오고, 그다음에 endobj가 옵니다. 첫 번째 정수는 객체 번호이고 두 번째 정수는 세대(generation) 번호입니다. 새로 작성된 파일에서 세대는 거의 항상 0이며, 여러 편집을 거쳐 객체 번호가 재사용될 때만 올라갑니다. 이것은 매우 드물기 때문에 0이 아닌 세대는 파일이 증분 업데이트(incremental updates)를 거쳤다는 신호로 취급할 수 있습니다. 여기서는 키워드 사이의 내용이 <<>>로 쓰인 딕셔너리이지만, 숫자, 문자열, 배열 또는 스트림이 될 수도 있습니다

이것을 리스트가 아닌 그래프(graph)로 만드는 것은 참조 토큰(reference token) 2 0 R입니다. 이는 "객체 2, 세대 0, 그게 파일 어디에 있든 간에"를 의미합니다. 위의 페이지 트리 노드는 자신의 페이지 자체를 담고 있지 않고 2번 객체를 가리키며, 2번 객체 역시 같은 메커니즘을 통해 자신의 리소스와 콘텐츠 스트림을 가리킵니다. 본문은 작성자가 편리하다고 생각한 순서대로 배열되며, 참조를 통해 카탈로그를 루트로 하는 트리 구조로 연결됩니다. 파일 내 위치는 의미가 없습니다. 식별자(Identity)는 객체 번호에서 오고, 위치(location)는 상호 참조 테이블에서 옵니다

상호 참조 테이블은 바이트 오프셋 인덱스입니다

xref 테이블은 객체 번호를 파일 위치로 변환하는 장치입니다. 이것이 바로 리더가 1000페이지짜리 문서를 열고 그 앞의 849페이지를 파싱하지 않고도 850번째 페이지를 렌더링할 수 있는 이유입니다. 각 항목은 객체가 파일의 시작 부분부터 바이트 단위로 계산되어 정확히 어디에서 시작하는지 기록합니다:

xref
0 6                  % 6개의 항목, 객체 0부터 시작
0000000000 65535 f   % 항목 0: 여유(free) 목록의 헤드(head)
0000000015 00000 n   % 객체 1은 15바이트에서 시작
0000000074 00000 n   % 객체 2는 74바이트에서 시작
0000000192 00000 n   % 객체 3은 192바이트에서 시작
0000000291 00000 n   % 객체 4는 291바이트에서 시작
0000000409 00000 n   % 객체 5는 409바이트에서 시작

고정 너비는 의도된 것입니다. 모든 항목은 정확히 20바이트입니다: 10자리의 오프셋, 빈칸, 5자리의 세대(generation), 빈칸, 1문자의 타입(type), 그리고 2바이트의 줄 끝(end-of-line). 행(row)이 균일하기 때문에 리더는 스캔 대신 산술 연산을 통해 n번 객체의 항목으로 곧바로 인덱싱할 수 있으므로, 본문에 대한 무작위 액세스를 제공하는 테이블 자체도 무작위로 접근할 수 있게 됩니다. 0 6 줄은 하위 섹션 헤더입니다: 다음 항목들이 0번부터 시작하는 6개의 객체를 설명한다는 의미입니다

객체 0은 특별하며 항상 존재합니다. 그것의 타입은 여유를 뜻하는 f(free)이고, 세대는 65535이며, 여유 객체 번호들로 이루어진 연결 리스트(linked list)의 헤드(head) 역할을 합니다. 편집된 적 없는 파일에서는 이 무료(free) 목록이 오직 이 항목 하나뿐이며 형식적인 것에 불과합니다. 그러나 객체를 삭제하면 그 번호가 목록에 추가되고 나중에 수행되는 편집이 그 번호를 회수할 수 있는 증분 업데이트 시에 제 몫을 다하게 됩니다. 다른 항목들은 사용 중임을 나타내는 n(in-use) 타입이며, 그 10자리 숫자는 해당 객체의 정의를 읽기 위해 이동해야 할 오프셋을 나타냅니다

트레일러는 진입점이며 파일의 끝에 위치합니다

트레일러(trailer)는 가장 마지막에 작성되지만, 뷰어(리더)가 실제로 가장 먼저 소비(consume)하는 항목입니다. 파서는 파일을 열고, 끝으로 이동한 다음, 뒤로 거슬러 올라가며 %%EOF를 찾습니다. 그 바로 위에는 startxref와 단일 숫자가 오는데, 이 숫자가 xref 키워드의 바이트 오프셋입니다. 이것을 가지고 리더는 단 하나의 객체도 스캔하지 않고 상호 참조 테이블로 곧장 점프합니다:

trailer
<<
/Root 5 0 R          % 문서 카탈로그
/Size 6              % 가장 높은 객체 번호보다 하나 더 큰 수
>>
startxref
459                  % xref 테이블의 바이트 오프셋
%%EOF

트레일러 딕셔너리는 뷰어가 다른 어떤 것을 수행하기 전에 필요로 하는 두 가지 값을 지닙니다. /Root는 객체 그래프의 최상단이자 페이지 트리로 가는 경로인 문서 카탈로그(여기서는 5번 객체)를 가리킵니다. /Size는 슬롯 0에 위치하는 여유 항목(free entry) 때문에 가장 높은 객체 번호보다 1만큼 더 크며, 상호 참조 테이블이 포함해야 하는 항목의 수입니다. 전체 읽기 순서는 %%EOF부터 차례대로 이루어집니다: 마커를 찾고, startxref를 읽어 테이블의 위치를 찾으며, 테이블을 로드하여 모든 객체가 어디에 있는지 알아내고, /Root를 읽어 카탈로그를 찾은 다음 거기에서 요구되는 객체를 찾아냅니다(resolve). 최상단에 있는 헤더(header)는 아주 나중까지 거의 참조되지 않습니다. 맨 아래에 있는 맵(map)이 뷰어에게 가장 먼저 필요한 것입니다

증분 업데이트(Incremental update)는 다시 쓰는 대신 두 번째 맵을 추가합니다

이러한 꼬리 먼저(tail-first) 설계는 파일이 변경될 때 빛을 발합니다. PDF는 디스크에 이미 기록된 바이트를 다시 쓰지 않고도 편집할 수 있습니다. 새 객체와 수정된 객체는 끝에 추가되고, 그다음 새로운 상호 참조 섹션과 새로운 트레일러가 뒤따르며, 그 아래의 원본 파일은 손대지 않은 채로 남겨집니다. 이 새로운 기장(bookkeeping) 작업의 한 가지 추가 요소는 새 트레일러에 있는 /Prev 항목으로, 여기에는 이전 상호 참조 테이블의 바이트 오프셋이 들어갑니다:

% ... 원본 파일은 변경되지 않은 채로 여기서 끝납니다 ...

6 0 obj                          % 이 편집에 의해 추가된 객체
<< /Type /Annot /Subtype /Text /Rect [100 700 120 720] >>
endobj

xref                             % 새 객체만을 위한 두 번째 xref 섹션
6 1
0000000612 00000 n

trailer
<<
/Root 5 0 R
/Size 7
/Prev 459                        % 이전 xref 테이블의 바이트 오프셋
>>
startxref
680                              % 이 새로운 xref 섹션의 오프셋
%%EOF

리더는 여전히 마지막 %%EOF에서 시작하고 여전히 가장 최근 테이블에 대한 startxref를 따르지만, 이제는 /Prev 체인을 이전 테이블로 역추적하여 모든 객체 번호에 대해 가장 최신 항목이 적용되도록(wins) 그것들을 병합(merge)합니다. 상호 참조 섹션들은 파일을 통해 연결 리스트를 형성하며, 각 섹션은 그것이 닿는 객체에 대해 이전 항목보다 우선시(overriding)됩니다. 편집으로 대체된 객체는 기존의 오프셋에 여전히 물리적으로 존재하지만, 나중에 추가된 xref 항목이 더 최신의 위치를 가리키므로 단순히 도달할 수 없게 될 뿐입니다

이것이 바로 서명된 PDF를 검증 가능하게 만드는 매커니즘입니다. 디지털 서명은 파일의 바이트 범위(byte range)를 커버하며, 증분 업데이트는 항상 추가만 수행하므로 서명된 바이트는 절대 이동하지 않습니다. 나중의 리비전(revision)들이 자체적인 xref와 트레일러와 함께 그 바이트를 넘어서는 곳에 위치하는 반면, 이 서명은 여전히 기존 원본 범위에 대해 유효성을 입증합니다. 또한 이는 PDF가 복구 가능한 히스토리(history)를 가질 수 있는 이유이기도 합니다: 대체된 모든 객체는 이전 상호 참조 섹션 아래의 디스크에 여전히 남아 있으며, 이는 버전 추적을 위한 훌륭한 기능인 동시에 "삭제"가 바이트가 사라졌음을 의미한다고 생각한 사람들에게는 치명적인 약점이 됩니다

비용은 크기 증가입니다. 각 편집은 뒤에 데이터를 추가하며(append); 제자리에서 회수(reclaimed in place)되는 것은 없으므로, 파일이 여러 번 수정되면 죽은 객체와 긴 줄의 xref 섹션 체인이 누적됩니다. 해결책은 전체 재작성(full rewrite)입니다: 문서를 로드하고 새로 저장하면 살아남은 객체의 번호를 다시 매기고 접근 불가능한 객체를 삭제하여 단일의 깨끗한 상호 참조 테이블을 출력합니다. 이 두 전략은 정면으로 맞교환(trade)됩니다. 끝에 추가하는 방식은 빠르고 서명과 히스토리를 보존하지만, 재작성은 파일이 압축(compact)되는 대가로 속도가 느리고 이 두 가지를 모두 버리게 됩니다

실전에서 4개의 파트 읽기

이 레이아웃만 알아도 대부분의 "이 파일이 열리지 않아요" 문제를 수동으로 디버깅하기에 충분합니다. 뷰어(리더)가 PDF를 거부한다면, 일반적으로 문제의 원인은 중간이 아니라 양 끝에 있습니다. 중간에 끊긴 다운로드는 트레일러를 상실하여 startxref%%EOF가 누락되므로 뷰어가 진입점(entry point)을 찾지 못하게 합니다. 관대한 리더는 전체 파일을 스캔하여 xref를 재구축하는 것으로 후퇴하지만, 이것이야말로 그 테이블이 피하고자 했던 느린 방식(slow path)입니다. 텍스트 모드 전송이 실패하면 스트림 바이트가 손상되거나 오프셋이 실제와 일치하지 않게 되어, 객체가 잘못된 위치에서 로드됩니다. 테이블의 오프셋이 더 이상 실제 obj 키워드를 가리키지 않으면 모든 객체가 개별적으로 멀쩡하더라도 파일은 구조적으로 깨진 상태가 됩니다

새로운 코드를 짤 때 이 레이아웃의 교훈은 라이브러리가 바이트 부기(byte bookkeeping)를 소유하게 놔두는 것입니다. 상호 참조 테이블의 오프셋은 모든 객체의 실제 위치와 바이트 단위로 일치해야 하고, 트레일러는 올바른 테이블을 가리켜야 하며, 증분 업데이트는 /Prev를 통해 올바르게 연결되어야 합니다. Delphi와 C++Builder를 위한 HotPDF 컴포넌트와 같은 네이티브 컴포넌트는 파일을 쓸 때 증분 리비전을 덧붙일지 압축된 것을 다시 작성할지 선택하는 것을 포함하여 이러한 모든 것을 처리합니다. 동일한 구조가 해부(dissected)되는 것이 아니라 무(nothing)에서 어떻게 구축되는지 보고 싶다면 스크래치부터 PDF 문서 구축하기에 대한 안내글을 읽어보십시오. 헤더, 객체, xref, 그리고 트레일러를 순서대로 생성하는(emitting) 과정을 단계별로 설명합니다