콘텐츠로 이동

PDF의 실체

Evidence: Standard-backed

PDF는 파일 안에 우연히 담긴 페이지 기술서가 아닙니다. PDF는 프린터가 붙은 작은 그래프 데이터베이스입니다. 이 페이지에서는 모든 PDF가 갖는 네 부분 — 헤더, 본문, 상호 참조 테이블, 트레일러 — 과, 리더가 추측 없이 모든 객체를 찾을 수 있도록 NextPDF가 이를 어떻게 기록하는지를 설명합니다.

대부분의 PDF 버그는 렌더링 버그가 아닙니다. 구조 버그입니다. 가리켜야 할 객체보다 한 글자 뒤를 가리키는 바이트 오프셋, 잘못된 루트를 지정하는 트레일러, 객체가 실제로 위치한 곳과 어긋나는 상호 참조 항목 같은 것들입니다. 이런 것들은 리더가 파일을 다른 경로로 따라가다 그 끝에서 벗어나기 전까지는 페이지의 겉모습을 전혀 바꾸지 않습니다.

PDF를 불투명한 대상으로 취급하면 이러한 실패는 무작위처럼 보입니다. 객체 모델을 알면, 이러한 실패는 있는 그대로, 즉 위치와 일치하지 않는 숫자로 드러납니다. 포맷을 읽을 줄 안다는 것은 “PDF가 손상되었다”와 “객체 14의 오프셋이 오래된 값인 이유는 writer가 스트림 길이가 확정되기 전에 그것을 측정했기 때문이다”의 차이를 안다는 뜻입니다.

PDF는 파일 순서에 따라 네 부분으로 구성됩니다.

  1. 헤더 — 버전을 지정하는 한 줄(%PDF-2.0)입니다.
  2. 본문 — 번호가 매겨진 간접 객체의 연속입니다. 딕셔너리, 스트림, 배열, 숫자, 문자열, 이름이 여기에 속합니다.
  3. 상호 참조 테이블(또는 PDF 2.0에서는 상호 참조 스트림) — 객체 번호에서 바이트 오프셋으로의 조회로, 파일을 스캔하지 않고도 어떤 객체에든 도달할 수 있게 합니다.
  4. 트레일러 — 문서의 루트 객체를 지정하고 상호 참조 섹션이 시작되는 위치를 가리키는 작은 딕셔너리입니다.

리더는 PDF를 앞에서 뒤로 읽지 않습니다. 마지막 줄을 먼저 읽어 startxref를 찾고, 상호 참조 섹션으로 점프한 다음, 이를 본문에 대한 인덱스로 사용합니다. 이 포맷은 거꾸로 읽도록 설계되어 있습니다. 이 한 가지 사실이 설계의 대부분을 설명합니다.

NextPDF는 포맷이 읽히는 방식 그대로 PDF를 구성합니다. 객체를 먼저 쓰고, 그다음에 오프셋을 기록하며, 테이블을 마지막에 작성합니다.

모든 간접 객체는 단일 레지스트리(src/Core/ObjectRegistry.php)에 의해 번호를 할당받습니다. 레지스트리는 allocate()를 통해 순차 번호를 발급하고, 객체의 바이트가 출력 버퍼에 기록된 후에 register()를 통해 바이트 오프셋을 기록합니다. 오프셋은 결코 미리 추측되지 않습니다. 오프셋은 객체 헤더가 출력되는 순간에 BinaryBuffer::getOffset()에서 관찰됩니다. 이것이 NextPDF 상호 참조 항목이 그것이 설명하는 객체와 어긋날 수 없는 이유입니다. 오프셋은 그 순간 버퍼의 실제 위치값이기 때문입니다.

본문이 완성되면, 버전별 직렬화 전략(src/Writer/PdfSerializationStrategy.php)이 상호 참조 섹션과 트레일러를 작성합니다.

  • Pdf20StreamStrategy는 압축된 상호 참조 스트림(/Type /XRef)을 출력하며, 이것이 PDF 2.0의 기본값입니다.
  • Pdf17TableStrategyPdf14TableStrategy는 전통적인 20 바이트 상호 참조 테이블과 별도의 트레일러 딕셔너리를 출력합니다. 이는 더 오래된 파일 구조를 요구하는 PDF/A 프로파일에서 필요합니다.

전략은 출력 프로파일에 의해 선택되며, 추측으로 정해지지 않습니다. 어느 쪽이든 최종 바이트의 형태는 동일합니다. 상호 참조 섹션, 그다음 startxref, 그다음 바이트 오프셋, 그다음 %%EOF 순입니다. 이 꼬리 부분이 리더가 가장 먼저 찾는 것입니다.

  1. Step 1 of 4: ISO 32000-2 §7.5.5 — %%EOF and startxref at the file end
  2. Step 2 of 4: ISO 32000-2 §7.5.4 / §7.5.8 — the cross-reference section maps object number to offset
  3. Step 3 of 4: ISO 32000-2 §7.5.5 — the trailer names /Root, the document catalog
  4. Step 4 of 4: ISO 32000-2 §7.3.10 — each indirect object is reached at its recorded offset
리더가 NextPDF 파일에서 객체를 해석하는 방법과, 각 단계를 정의하는 ISO 32000-2 절: 파일의 끝에서 시작해 안쪽으로 진행합니다.

이 네 부분 구조는 NextPDF의 관례가 아닙니다. 이는 Spec: ISO 32000-2, §7.5 의 파일 구조 절입니다. 이 표준은 PDF를 헤더, 객체로 이루어진 본문, 상호 참조 테이블, 트레일러로 정의하며, 리더가 파일의 끝에서부터 파싱해야 한다고 명시합니다. 마지막 줄은 %%EOF이고, 그 앞의 두 줄은 startxref 키워드와 상호 참조 섹션에 대한 바이트 오프셋입니다.

Evidence: Standard-backed

간접 객체는 공백으로 구분된 객체 번호와 세대 번호로 정의되며, 그 뒤에는 objendobj 키워드 사이에 묶인 객체 값이 옵니다. 객체 번호와 세대 번호의 조합은 객체를 고유하게 식별합니다. 그 객체에 대한 간접 참조는 객체 번호, 세대 번호, 그리고 키워드 R로 작성됩니다. NextPDF의 ObjectRegistry는 이를 정확히 그대로 반영합니다. 순차 번호, 새로 작성된 객체에 대한 세대 0, 그리고 기록된 오프셋입니다.

PDF 1.5부터는 객체가 객체 스트림 안에 존재하는 것도 허용되며, 그 안에서는 obj/endobj 키워드 없이 저장되고 세대가 0이어야 합니다. 상호 참조 스트림(/Type /XRef, Spec: ISO 32000-2, §7.5.8 )은 PDF 2.0에서 일반 객체와 이러한 압축 객체를 모두 색인하는 메커니즘입니다. NextPDF의 CrossReferenceStream/W 필드 너비 배열과 FlateDecode 압축으로 이를 구성합니다.

이것이 최소한의 PDF 본문과 그 트레일러의 형태입니다. 상호 참조 섹션의 숫자는 바이트 오프셋입니다. 이 값들은 정확히 맞아야 하며, 그렇기 때문에 NextPDF는 이를 계산으로 맞추지 않고 버퍼에서 기록합니다.

%PDF-2.0
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] >>
endobj
xref
0 4
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000122 00000 n
trailer
<< /Size 4 /Root 1 0 R >>
startxref
196
%%EOF

리더는 이를 아래쪽부터 엽니다. %%EOF, 그다음 startxref 196을 읽고, 바이트 196으로 이동해 거기서 xref가 시작됨을 확인하며, 객체 1이 바이트 9에 위치한다는 것을 읽고, /Root 1 0 R을 따라 카탈로그로 가서, 거기서부터 페이지 트리를 탐색합니다. 객체 0은 항상 세대 65535를 갖는 프리 리스트 헤드입니다. 이는 포맷의 초창기 설계에서 물려받은 특이점으로, 리더가 이를 기대하기 때문에 충실하게 재현됩니다.

흔한 함정은 PDF가 소스 코드처럼 위에서 아래로 읽힌다고 믿는 것입니다. 그렇지 않습니다. 본문은 어떤 객체 순서로든 배치될 수 있습니다. 객체 번호는 파일 안에서 순차적일 필요가 없으며, 리더는 결코 그것이 순차적이라는 데 의존하지 않습니다. 유일하게 권위 있는 인덱스는 상호 참조 섹션이며, 이를 찾는 유일한 방법은 끝에 있는 트레일러입니다. 완벽하게 유효한 본문을 가졌더라도 startxref에 잘못된 숫자가 하나라도 있는 PDF는 읽을 수 없습니다. 객체가 뒤섞인 순서로 작성되었더라도 상호 참조 테이블이 올바른 PDF는 문제없습니다. 위치 자체는 무의미하며, 기록된 위치가 전부입니다.

이 페이지는 페이지 콘텐츠가 아니라 파일 구조를 설명합니다. 페이지 위에 마크가 어떻게 그려지는지 — 콘텐츠 스트림, 그래픽 연산자, 텍스트 표시 — 는 별개의 주제입니다. 또한 파일이 작성된 후에 변경될 때 어떤 일이 일어나는지도 다루지 않습니다. 그 부분은 증분 업데이트의 영역으로, 거기서는 두 번째 상호 참조 섹션이 덧붙여지고 트레일러가 거꾸로 연결됩니다.

NextPDF는 writer입니다. 여기서 설명하는 동작은 NextPDF가 직접 구성한 문서를 직렬화하는 방식에 관한 것입니다. 이는 범용 PDF 파서나 복구 도구가 아닙니다. 손상된 상호 참조 테이블을 가진 임의의 타사 파일을 읽거나, 재구성하거나, 복원하겠다고 약속하지 않습니다. 이 보장은 의도적으로 좁습니다. NextPDF가 작성하는 파일은 오프셋이 일치합니다. 오프셋이 예측된 것이 아니라 측정된 것이기 때문입니다.

새 파일이 항상 0을 사용한다면 왜 세대 번호가 필요할까요? 세대 번호는 업데이트 과정에서 객체를 재사용하기 위해 존재합니다. 새로 작성된 파일은 모든 객체가 세대 0에 있습니다. 0이 아닌 세대는 파일이 증분 업데이트되어 객체 번호가 재활용될 때에만 나타납니다.

두 객체가 같은 번호를 가질 수 있을까요? 단일 상호 참조 섹션 안에서는 불가능합니다. 증분 업데이트 전반에 걸쳐서는 파일이 같은 객체 번호의 복사본을 물리적으로 여러 개 포함할 수 있습니다. 가장 최근의 상호 참조 항목이 우선합니다. 이것이 다음 페이지의 주제입니다.

파일 내 객체 순서가 출력에 영향을 줄까요? 아닙니다. NextPDF는 재현 가능한 빌드를 위해 객체를 결정론적 순서로 작성하지만, 리더는 모든 것을 상호 참조 섹션을 통해 해석하므로 물리적 순서는 의미상 중요하지 않습니다.

  • 간접 객체 — 본문에 있는 번호가 매겨진 객체로, N G obj … endobj로 작성됩니다. 여기서 N은 객체 번호이고 G는 세대 번호입니다.
  • 간접 참조 — 간접 객체에 대한 포인터로, N G R로 작성됩니다.
  • 상호 참조 테이블(xref) — 객체 번호에서 바이트 오프셋으로의 인덱스입니다. PDF 2.0에서 이것은 대개 항목당 20 바이트의 고전적인 텍스트 테이블 대신 상호 참조 스트림(/Type /XRef)입니다.
  • 트레일러 — 상호 참조 섹션 끝에 있는 딕셔너리로, /Root(문서 카탈로그)과 /Size를 지정하며, startxref 오프셋을 통해 찾습니다.
  • 객체 스트림 — 그 자체가 다른 간접 객체를 (함께 압축하여) 포함하는 스트림 객체입니다. 구성원에는 obj/endobj가 없으며 세대는 0입니다.
  • 문서 카탈로그/Root로 지정되는 객체로, 페이지 트리와 문서 내 다른 모든 것에 대한 진입점입니다.