기술 문서

Pages 딕셔너리가 없는 PDF: 파싱에 미치는 영향

PDF 카탈로그(Catalog) 딕셔너리에는 정확히 하나의 필수 내비게이션 키가 있습니다: /Pages입니다. 이 키는 /Pages 유형의 간접 객체를 가리켜야 하며, 해당 객체는 다시 /Kids 배열과 페이지의 전체 /Count(개수)를 유지합니다. 이 포인터를 없애버리면 규격을 준수하는 그 어떤 리더(뷰어)도 파일 내에서 단 하나의 페이지조차 찾을 수 없게 됩니다. ISO 32000-1 §7.7.2는 이 점에 대해 명확합니다: 카탈로그는 반드시 /Pages 항목을 지녀야 하며, 참조되는 객체의 유형은 /Pages여야 합니다. 이 요구사항을 위반하는 파일은 단순히 규격을 벗어난 정도가 아닙니다; 대부분의 파서가 제대로 처리하지 못할 만큼 구조적으로 손상된 것입니다

사양이 실제로 명시하는 내용

규격을 준수하는 최소한의 PDF는 최소 3개의 객체를 가집니다. 객체 1은 카탈로그, 객체 2는 Pages 루트이며, 객체 3부터는 개별 Page 딕셔너리입니다. 카탈로그는 Pages 루트를 가리킵니다; Pages 루트는 /Kids에 자식 노드들을 나열합니다; 각 Page는 다시 /Parent 역참조(back-reference)를 지닙니다. 전체 체인은 설계상 양방향이므로, 파서는 어느 쪽 끝에서든 시작하여 균형 잡힌 트리(balanced trees)의 경우 O(log n)의 시간 안에 어떤 페이지로든 탐색(traverse)할 수 있습니다

% 최소 규격 준수 구조 (ISO 32000-1 §7.7.2)
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>
endobj

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

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

Pages 트리는 중첩(nested)될 수 있습니다. 수천 페이지에 달하는 문서는 일반적으로 페이지들을 /Pages 유형을 지닌 중간 노드 객체들로 그룹화하며, 각 노드는 고유한 /Kids와 해당 하위 트리(subtree)의 페이지 수를 나타내는 /Count를 갖습니다. 루트 노드의 /Count는 항상 전체 페이지 수와 일치합니다. 객체 2에서 하나의 정수(integer)를 읽어내는 것이 전체 트리를 거니는 것보다 훨씬 비용이 적게 들기 때문에, 뷰어(리더)가 단일 페이지를 파싱하기도 전에 페이지 번호 필드에 표시하는 것이 바로 이 개수입니다

Pages가 없는 파일의 모습

Pages 딕셔너리가 누락된 파일은 보통 페이지 객체들을 트리 구조로 조립하지 않고 직접 작성하는 PDF 생성기나, 리프(leaf) Page 객체들은 온전히 남겨둔 채 루트 노드만을 제거하는 형태의 손상(corruption)에서 비롯됩니다. 이런 파일의 카탈로그에는 /Pages 키가 아예 없거나, 상호 참조 테이블에 더 이상 존재하지 않는 객체에 대한 참조를 지니고 있습니다

% 규격 위반: /Pages 참조가 없는 카탈로그
1 0 obj
<< /Type /Catalog >>
endobj

% Page 객체들은 존재하지만 카탈로그에서는 도달할 수 없음
5 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 6 0 R /Resources << >> >>
endobj

15 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 16 0 R /Resources << >> >>
endobj

25 0 obj
<< /Type /Page /MediaBox [0 0 612 792] /Contents 26 0 R /Resources << >> >>
endobj

사양을 따르는 파서는 카탈로그를 읽고, /Pages를 해석(resolve)하려 시도하고, 아무것도 찾지 못하거나(혹은 죽은 참조를 찾거나), 오류를 발생시키거나 0페이지로 보고할 것입니다. 파서가 절대 하지 말아야 할 일은 파일의 페이지가 0개인 것처럼 처리하고 조용히 성공(silently succeed)하는 것입니다; 이는 자동화된 도구에는 올바른 것처럼 보이지만 그것을 여는 모든 사람에게는 잘못된 형태인 빈 출력을 생성하게 됩니다

파서가 충돌하는 이유

대부분의 PDF 파서들은 문서를 로드할 때 Pages 루트의 /Count 값에 기반하여 내부 페이지 테이블을 할당(allocate)합니다. 이 루트가 존재하지 않으면, 파서는 0을 읽고 아무것도 할당하지 않은 뒤 코드에서 1페이지를 처음 요청할 때 널(null) 포인터를 역참조(dereferences)하거나, 쓰레기 값(garbage)을 읽고 터무니없이 잘못된 버퍼를 할당합니다. 어느 쪽이든 우아한 결과는 아닙니다. 이런 파일을 처리하다 충돌 로그(crash logs)에 나타나는 0x008E5D78의 액세스 위반은 정확히 이런 현상입니다: 파서가 항상 있을 것으로 가정했던 구조가 없기 때문에 페이지 액세스 경로 내에서 유발된 널 포인터 역참조입니다

근본적인 설계 가정(design assumption)은 합리적입니다. 현존하는 방대한 대다수 PDF에는 Pages 딕셔너리가 존재합니다. 몇 개의 명령어를 절약하고자 존재 확인 절차를 생략하는 파서들은 결코 무모한 것이 아닙니다; 그들은 흔한 상황에 맞춰 최적화(optimizing)를 하고 있는 것입니다. 이러한 최적화를 징벌(punish)하는 파일들은 실무 코드가 그들을 실제로 만나기 전까지는 좀처럼 마주치기 힘들 정도로 드물며, 엔지니어가 §7.7.2 항목을 읽어보지 않았다면 이 시점에서의 충돌은 재현 가능하면서도 매우 당혹스러울 것입니다

Pages 트리 없이 복구하기

만약 파서가 이러한 파일들을 거부하는 대신 꼭 처리해야만 한다면, 복구는 예측 가능한 경로를 따르게 됩니다: 상호 참조 테이블에 있는 모든 간접 객체를 스캔하고, /Type /Page를 지닌 것들을 모은 뒤, 객체 번호순으로 정렬합니다. 사양에서 객체 번호 순서가 문서 읽기 순서와 일치한다고 보장하지는 않지만, 현실에서는 Pages 트리를 생략하는 생성기들이 주로 페이지를 순차적으로 배출(emit)하는 경향이 있으므로 객체 번호 순서가 올바른 경우가 더 많습니다

이 확인(check) 작업 자체는 비용이 적게 듭니다. 카탈로그의 /Pages 포인터를 거닐기 전에 포인터가 존재하는지, 실제 객체로 해석되는지, 그리고 해석된 객체의 /Type/Pages와 같은지 확인하십시오. 이 세 가지 조건 중 어느 하나라도 실패하면(falls through), 선형 스캔(linear scan)을 진행합니다. 선형 스캔은 균형 잡힌 경로를 따르는 대신 모든 객체의 헤더를 읽어내야 하므로 대용량 문서에서는 트리 탐색보다 느리지만 작동은 성공적이며, 이미 기형적으로 만들어진 파일에 대해서는 정확성(correctness)이 속도(speed)보다 우선합니다

선형 스캔이 자동으로 해결할 수 없는 한 가지 예외 사례(edge case)가 있는데 바로 페이지 순서(page ordering)입니다. 순서를 정의하는 /Kids 배열이 없다면, "올바른" 순서는 사양에 의해 정의되지 않은 상태입니다. 객체 번호 순서는 실용적인(pragmatic) 기본값입니다; 해당 파일이 세심하게 처리해야 할 만큼 중요하다면, Page 객체들이 읽기 순서를 암시하는 명시적 /StructParents나 주석 참조(annotation references)를 지니고 있는지 확인하는 것은 추가적인 노력을 들일 만한 가치가 있습니다

PDF 생성기에게 주는 시사점

파서가 아닌 PDF 생성기(generator)를 개발하는 모든 이에게 교훈은 명확합니다: 파일을 닫기(closing) 전에 항상 Pages 루트를 배출(emit)하십시오. /Pages 항목이 없는 카탈로그는 사양의 어떤 리비전(revision) 기준으로도 유효한 PDF가 아닙니다. 생성 과정(on the fly)에서 페이지 객체를 구축하고 마무리(finalization) 단계에서 트리를 조립하는 생성기들(대부분의 스트리밍 작성기가 사용하는 방식)은 마무리 작업이 실제로 실행되는 한 문제가 없습니다. 흔한 실패 모드는 트레일러(trailer)가 완성되기도 전에 쓰기(write) 작업을 중단시키는 예외 처리나 조기 반환(early return)이며, 그 결과 일부 뷰어(복구 휴리스틱스(recovery heuristics)를 갖춘 경우)에서는 열리지만 다른 뷰어(갖추지 않은 경우)에서는 열리지 않는 파일을 남기게 됩니다

PDF/A와 PDF/UA는 기본 사양이 요구하는 것 이상의 추가적인 제약(constraints)을 페이지 트리에 부과하지만, /Pages에 대한 요구사항만큼은 결코 완화(relaxes)하지 않습니다. ISO 19005나 ISO 14289의 적합성을 확인하는 유효성 검사기(validator)는 누락된 Pages 딕셔너리가 프로필별 규칙(profile-specific rules)에 채 도달하기도 전에 이를 기본 사양 위반으로 잡아낼 것입니다