기술 문서

PDF 페이지 순서: 페이지 트리가 페이지 순서를 제어하는 방법

객체 1번이 1페이지가 아닙니다. 이 하나의 사실 때문에 형식의 다른 어떤 부분보다 더 많은 PDF 처리 코드가 실패하며, 그 이유를 이해하려면 뷰어가 보여주는 것을 넘어 뷰어가 실제로 읽는 객체 그래프(object graph)를 들여다봐야 합니다

PDF 파일은 번호가 매겨진 간접 객체(indirect object)들의 모음입니다. 각 객체는 객체 번호(object number)와 세대 번호(generation number)를 가지며, 다른 객체들은 N G R 형식으로 작성된 참조를 사용하여 이를 가리킵니다. 예를 들어 3 0 R은 객체 3의 현재 버전을 의미합니다. 페이지도 이러한 객체들 중 하나이지만 표시되는 순서는 파일 내 위치나 번호와 아무 상관이 없습니다. 표시 순서는 문서 카탈로그(document catalog)를 루트로 하는 링크 구조인 /Pages 트리에 의해 전적으로 결정됩니다. 트리를 무시하고 객체들을 숫자 순으로 스캔하면 실제 파일들 중 상당 부분에서 페이지들이 잘못된 순서로 조립될 것입니다

페이지 트리: 순서를 실제로 설정하는 것

모든 PDF는 문서 카탈로그(ISO 32000-2 §7.7.2)로 시작합니다. 카탈로그에는 페이지 트리의 루트 노드를 가리키는 /Pages 항목이 있습니다. 그 루트 노드는 /Type /Pages, 간접 참조의 배열인 /Kids, 그리고 그 아래에 있는 총 잎(leaf) 페이지 개수를 나타내는 /Count가 있는 딕셔너리(dictionary)입니다. 표시 순서는 해당 트리를 왼쪽에서 오른쪽으로 깊이 우선 순회(depth-first left-to-right traversal)하는 것에 지나지 않습니다

최소한의 3페이지 파일이 이를 구체적으로 보여줍니다

%PDF-1.7

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

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

% 객체 4는 파일의 세 번째에 저장되지만 표시 순서로는 2페이지입니다
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% 객체 9는 파일의 네 번째에 저장되지만 3페이지입니다
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 10 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% 객체 20은 맨 마지막에 저장되지만 1페이지입니다. 객체 번호가 아니라 Kids[0]이 결정합니다.
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 21 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

/Kids 배열은 [20 0 R 4 0 R 9 0 R]이므로 객체 20이 1페이지, 객체 4가 2페이지, 객체 9가 3페이지입니다. 객체 번호는 무관합니다. 객체를 숫자 순으로 반복하며 /Type /Page를 포함한 것들을 수집하는 모든 코드는 이 파일에서 잘못된 시퀀스를 생성할 것입니다

생성기가 왜 비순차적 레이아웃을 생성할까요? 몇 가지 이유가 있습니다. 콘텐츠를 쓰기 전에 모든 페이지의 객체 번호를 미리 할당하는 라이브러리는 생성 순서대로 번호를 매긴 다음 직렬화 프로그램(serializer)에 적합한 순서로 실제 바이트를 기록합니다. 문서를 함께 꿰매는 병합 도구는 충돌을 피하기 위해 각 원본 문서에서 객체 번호를 다시 매깁니다. 다시 번호가 매겨진 페이지 객체들은 통합된 객체 테이블에 흩어지게 되고 새 루트의 /Kids 배열이 올바른 표시 순서를 유지합니다. 점진적 업데이트(incremental updates)는 파일 끝에 새로운 객체를 신규 번호와 함께 추가하므로, 개정본으로 추가된 페이지는 표시 순서에서 1번 위치에 속하더라도 바이트 스트림의 끝 쪽에 있게 됩니다

평면 트리 및 중첩 하위 트리

사양은 페이지 트리에 대해 두 가지 모양을 허용합니다. 단순 생성기는 평면(flat) 구조를 생성합니다. 즉 하나의 루트 /Pages 노드가 있고 그 /Kids 배열에 /Page 잎(leaf) 객체들만 들어 있습니다. 이는 순회하기 쉽습니다. 1단계 깊이이고 한 번의 패스로 끝납니다

큰 문서는 일반적으로 평형(balanced) 트리를 대신 사용합니다. 루트 /Pages 노드의 /Kids 배열에는 중간 /Pages 노드들이 포함되며, 각 노드는 다시 자체적인 /Kids 배열을 가집니다. 각 중간 노드의 /Count는 해당 하위 트리(subtree)에 있는 총 잎 페이지 수를 보고하므로 뷰어는 인덱스로 페이지를 이동할 때 모든 객체를 구문 분석(파싱)하지 않고 전체 하위 트리를 건너뛸 수 있습니다. 잎 노드당 10페이지가 있는 평형 트리로 구조화된 1,000페이지 문서는 750개의 /Kids 항목을 스캔하는 대신 3~4개의 딕셔너리 조회를 통한 이진 탐색(binary search)으로 750페이지를 찾을 수 있습니다

결과적으로 코드를 처리할 때 다음과 같은 사항에 유의해야 합니다. /Kids의 첫 번째 레벨에 /Page 객체가 포함되어 있다고 가정할 수 없습니다. 각 자식 요소는 확인되어야 합니다. /Type/Pages이면 그 안으로 재귀(recurse) 호출합니다. /Type/Page이면 그것은 잎(leaf)입니다. 첫 번째 레벨에서 멈추는 방식은 생성기가 중첩을 선택한 모든 문서에서 전체 하위 트리를 소리 없이 떨어뜨리게 됩니다. 작성기(writer)가 처음부터 깊은 트리를 선택하는 이유, 평면화 도구(flattening tools)가 포기하는 것, 그리고 /Count 손상이 실제로 어떻게 나타나는지는 페이지 트리 모양, 팬 아웃 및 /Count 무결성에 대한 동반 기사에서 다룹니다

상속된 페이지 속성

페이지 트리에는 리소스 공유 메커니즘도 있습니다. 특정 페이지 속성인 /MediaBox, /CropBox, /Resources/Rotate는 상속할 수 있습니다(ISO 32000-2 §7.7.3.4). /Page 딕셔너리에서 이 중 하나가 누락된 경우, 리더는 해당 속성을 찾거나 루트에 도달할 때까지 /Parent 체인을 위로 올라갑니다. 문서 전체에서 동일한 서체를 사용하는 경우 모든 잎 페이지마다 폰트 딕셔너리를 복사하는 대신 루트 /Pages 노드에 하나의 공유 폰트 딕셔너리를 배치하면 파일 크기를 눈에 띄게 줄일 수 있습니다

상속 규칙은 페이지 속성을 읽는 코드에 미묘함을 만들어냅니다. /Page 객체에서 /MediaBox를 직접 읽고 누락된 키를 오류로 처리하는 것은 잘못된 방법입니다. 키가 단순히 상속되었을 수 있습니다. 페이지 기하학을 올바르게 해결하는 코드는 부모 체인을 따라가야 합니다. 또한 순환 보호 장치(cycle guard)도 필요합니다. 손상된 파일은 이미 방문한 노드를 다시 가리키는 /Parent 참조를 가질 수 있으며 방문 객체(visited-object) 점검을 하지 않으면 무한 루프에 빠질 수 있습니다

xref 테이블 및 상호 참조 스트림

간접 객체 조회는 상호 참조 테이블(cross-reference table) (또는 PDF 1.5에 도입된 상호 참조 스트림)을 거쳐 수행됩니다. xref는 각 객체 번호를 파일 내 바이트 오프셋에 매핑합니다. 규격을 준수하는 리더는 xref를 사용하여 어떤 객체로든 바로 점프(jump)하며, 파일을 순차적으로 스캔하지 않습니다. 바로 이 무작위 접근(random-access) 디자인이 빠른 페이지 이동을 가능하게 합니다. 뷰어는 카탈로그를 읽고, xref를 통해 /Pages 참조를 해결하고, 루트 /Pages 노드를 읽고, /Kids 항목을 해결하는 식으로 필요한 객체만 건드립니다

점진적 업데이트(incremental updates)는 이전 트레일러(trailer)와 연결(chain)되는 새로운 xref 섹션을 파일의 맨 끝에 추가합니다. 개정판에서 업데이트된 객체는 추가된 xref 섹션에서 새 항목을 받게 되며 원본 바이트는 그대로 남아있지만 더 이상 사용되지 않습니다. 이것이 디지털 서명된 PDF가 주석 추가나 폼 채우기 후에도 검증 가능한 상태를 유지하는 방법입니다. 서명된 바이트 범위는 전혀 건드리지 않고 새로운 콘텐츠가 추가 섹션에 살게 됩니다. 페이지 트리도 업데이트할 수 있으므로 개정 시 페이지를 추가하거나 삭제하면 새로운 /Pages 루트와 개정된 /Kids 배열이 생성되는 반면, 기존 루트 객체는 파일 내 원래 위치를 차지합니다. 선형화(웹 최적화)된 파일에는 바이트 레이아웃 반전이 더해집니다. 즉 1페이지용 객체들이 파일 앞부분으로 물리적으로 이동하여 나머지 부분을 여전히 다운로드하는 동안에도 뷰어가 첫 페이지를 표시할 수 있게 하지만, 페이지 순서에 대한 유일한 권한은 여전히 페이지 트리에 있으며 변경되는 것은 xref에 기록된 오프셋뿐입니다

트리 순회가 없을 때 발생하는 문제

객체 스캔 접근 방식의 실패 양상은 조용합니다. 출력 문서는 그럴듯해 보입니다. 페이지 수가 맞고 각 페이지에 인식 가능한 콘텐츠가 들어 있습니다. 순서만 잘못되었을 뿐이며 발전기(generator), 리비전 수, 외부 소스에서 병합된 페이지가 있는지에 따라 잘못된 방식이 다릅니다. 하나의 도구에서 생성된 테스트용 파일 집합(corpus)은 완벽하게 통과할 수 있지만 다른 도구나 병합 워크플로에서 생성된 파일은 실패합니다. 이런 일관성 부재 때문에 휴리스틱(heuristic) 수정이 오래가지 못합니다. 증상, 오진 및 순회(traversal) 수정 등 실제 고객 문서에서 겪은 정확히 이러한 실패의 연습을 보려면 페이지 순서 디버깅 사례 연구를 참조하십시오

이후 수정판에 추가되거나 재배열된 페이지들은 높은 객체 번호를 가지지만 표시 순서는 업데이트된 /Kids 배열로 제어되므로 점진적 업데이트(incremental-update) 파일은 이 문제에 특히 취약합니다. 객체들을 숫자 순으로 처리하는 스캔은 트리가 있어야 한다고 지시하는 위치와 관계없이 뒤늦게 번호가 지정된 페이지들을 끝에 배치할 것입니다

해결책은 복잡하지 않습니다. 카탈로그에서 시작하여 /Pages 참조를 해결하고 /Kids 배열을 재귀적으로 순회하며 마주치는 순서대로 잎(leaves)을 출력하면 됩니다. 그것이 객체 번호, 바이트 오프셋 또는 파일 구조에 상관없이 정의에 따른 표시 순서입니다. 대부분의 완성도 있는 PDF 라이브러리는 이를 이미 올바르게 수행하는 페이지 수와 인덱스 기반 페이지 접근자(accessor)를 제공합니다. 위험은 라이브러리의 페이지 모델을 우회하고 객체 레이어를 직접 건드리는 코드에 있습니다

명시적으로 다룰 가치가 있는 구조적 예외 하나가 있습니다. 형식이 잘못된 파일의 경우 중간 /Pages 노드의 /Count 값이 잘못될 수 있습니다. /Count를 경계 점검(bounds checking)용으로 신뢰하고 전체 순회를 끝까지 하지 않으면 개수가 실제보다 적게 나타날 때 조용히 페이지가 누락됩니다. /Count는 용량 사전 할당이나 이진 탐색을 위한 성능 힌트용으로만 사용하고 실제 개수는 순회를 통해 구하는 것이 중요한 문서에 더 안전한 패턴입니다

 다음 기사