PDF 페이지 순서에 대한 동반 해설서에서는 기본적인 규칙을 다루고 있습니다. 표시 순서는 결코 객체 번호가 아니라 /Pages 트리 내 /Kids 배열의 왼쪽에서 오른쪽으로 향하는 깊이 우선 순회에서 나온다는 것입니다. 이 기사에서는 트리를 모양이라는 다른 각도에서 살펴봅니다. 단일 평면(flat) 배열도 완벽하게 합법적인데 왜 성숙한 PDF 작성기(writers)는 중간 노드의 계층 구조를 생성할까요? 도구가 트리를 평면화하거나 재구축할 때 실제로 무엇이 변경될까요? 그리고 전체 구조를 빠르게 만들어 주는 /Count 장부 기입(bookkeeping)이 진실을 말하지 않게 될 때 어떤 일이 일어날까요?
팬 아웃은 성능 결정 사항입니다
작성기가 구조를 중첩(nest)하도록 강제하는 것은 없습니다. 단일 /Kids 배열에 1개의 루트 /Pages 노드와 10,000개의 잎(leaf) 참조가 있는 10,000페이지 문서는 사양(spec)을 준수합니다. 그럼에도 불구하고 PDF 참조(Reference)는 큰 문서에 대해 평형 트리(balanced tree)를 권장하며, 주요 생성기들은 일반적으로 중간 노드당 수십 개의 자식(kids)이라는 완만한 팬 아웃(fan-out)으로 이 권장 사항을 따릅니다
그 이유는 뷰어가 무언가를 보여주기 전에 읽어야 하는 내용 때문입니다. 이 10,000페이지 파일의 8,214페이지로 바로 건너뛴다고 생각해 보십시오. 평면 트리(flat tree)의 경우 뷰어는 먼저 루트 노드를 파싱해야 하는데 그 루트 노드는 거대한 배열 하나입니다. 간접 참조당 약 8바이트라고 할 때, 8,213번 항목이 해결(resolve)되기 전에 끝에서 끝까지 토큰화해야 하는 80KB 크기의 객체입니다. 32개의 팬 아웃을 가진 평형 트리의 경우, 동일한 이동 과정에서 루트를 읽고, 누적된 /Count 합계를 비교하여 올바른 자식을 선택한 다음 내려갑니다. 즉 각각 수백 바이트인 작은 딕셔너리 서너 개만 있으면 됩니다. 이것이 트리가 제공하도록 설계된 O(log n) 무작위 접근(random access)이며 중간 노드에 /Count가 존재하는 전체 이유이기도 합니다. 이를 통해 리더(reader)는 전체 하위 트리를 열지 않고도 하위 트리를 건너뛸 수 있습니다
트리 모양은 편집 비용도 설정합니다. 한 페이지를 삽입하는 점진적 업데이트(incremental update)는 /Kids 또는 /Count가 변경된 모든 노드(즉, 새로운 잎의 부모에서 루트로 올라가는 경로)를 다시 작성해야 합니다. 평형 트리에서 이 경로는 파일에 추가되는 소수의 작은 딕셔너리들에 불과합니다. 평면 트리에서 "경로"는 개정 시마다 완전히 복제되는 거대한 단일 루트 배열입니다. 검토 및 주석 추가(review-and-annotate)를 서른 번이나 거친 계약서는 바이트 스트림 안에 쓸모없어진 동일한 80KB 배열의 사본 30개를 끌고 다니게 될 수 있습니다
내부 노드에는 상속된 속성이 포함됩니다
중간 노드는 단순히 경로 지정(routing)만 하는 것이 아닙니다. /Resources, /MediaBox, /CropBox, /Rotate의 네 가지 상속 가능한 페이지 속성은 어떤 /Pages 노드로든 끌어올려질(hoisted) 수 있으며, 후손(descendant)이 이를 재정의(override)하지 않는 한 그 아래의 모든 잎에 적용됩니다. 가로 모드(landscape) 부록이 있는 보고서를 작성하는 도구는 다음과 같이 트리 자체로 그 레이아웃을 표현할 수 있습니다
5 0 obj % 문서 루트
<< /Type /Pages /Count 6 /Kids [6 0 R 7 0 R] >>
endobj
6 0 obj % 보고서 본문: 세로 모드 A4, 본문 폰트
<< /Type /Pages /Parent 5 0 R /Count 3
/Kids [30 0 R 31 0 R 32 0 R]
/MediaBox [0 0 595 842]
/Resources << /Font << /F1 8 0 R >> >> >>
endobj
7 0 obj % 부록: 가로 모드 A4, 회전됨, 자체 폰트
<< /Type /Pages /Parent 5 0 R /Count 3
/Kids [40 0 R 41 0 R 42 0 R]
/MediaBox [0 0 842 595] /Rotate 90
/Resources << /Font << /F2 9 0 R >> >> >>
endobj
40 0 obj % 부록 페이지: 크기, 회전, 폰트 상속
<< /Type /Page /Parent 7 0 R /Contents 43 0 R >>
endobj
객체 40부터 42는 거의 비어 있습니다. 페이지 크기, 회전 및 폰트 리소스는 모두 노드 7에서 상속을 통해 전달되므로 파일이 간결하게 유지되고 자체적으로 유지관리(self-maintaining)됩니다. 부록 노드 아래에 네 번째 페이지를 추가하면 자동으로 가로 모드로 출력됩니다
동일한 메커니즘이 페이지 이동과 관련된 고전적인 위험(hazard)을 유발합니다. 어떤 도구가 두 개의 /Kids 배열을 편집하고 /Parent를 노드 6으로 다시 가리켜서 객체 40을 보고서 본문으로 옮겼다고 가정해 보십시오. 이 이동은 구조적으로 유효하지만, 이제 객체 40은 세로 모드(portrait) /MediaBox, 회전 없음, 폰트 /F1을 상속하는 반면 콘텐츠 스트림은 여전히 /F2를 선택하며 이는 더 이상 해결(resolve)되지 않습니다. 단 한 번의 편집으로 페이지가 축소되고 회전이 풀리고 텍스트를 잃습니다. 견고한 순서 변경(reordering) 코드는 상속 가능한 네 가지 속성 모두의 해결된 값들을 페이지 딕셔너리에 구체화(materialize)한 다음에 부모를 재지정(reparenting)합니다. 편집기에서 페이지를 끌어다 놓을 때 크기나 방향이 바뀌는 것을 본 적이 있다면 바로 이 메커니즘을 목격한 것입니다
평면화: 합법적이고 흔하며 때로는 비용이 많이 듭니다
상당수의 도구가 반대 길을 갑니다. 최소화된(minimal) 작성기는 단순성 때문에 단일 수준 트리를 출력하며, 많은 병합 및 분할 유틸리티(utilities)는 읽어 들인 트리가 무엇이든 /Kids 배열 하나로 평면화하여(flat) 재구축합니다. 왜냐하면 평형 구조를 생성하는 것은 추가 작업이고 평면적인 출력은 항상 사양을 준수하기 때문입니다. 올바른 재구축은 상속도 동시에 해결해야 합니다. 잎이 상속하던 모든 속성을 잎으로 복사하거나 문서 전체에 걸쳐 동일하다면 새로운 루트로 끌어올려야(hoist) 합니다. 그렇지 않으면 위 페이지 이동 사례와 정확히 동일한 방식으로 출력의 기하구조(geometry)가 변경됩니다
일반적인 문서의 경우 평면화(flattening)는 해롭지 않습니다. 평면화가 문제가 되는 것은 문서 규모가 클 때이며, 앞서 설명한 두 가지 방식으로 작용합니다. 즉 루트 배열이 거대한 하나의 객체가 되어 열기 작업과 모든 페이지 점프 시 이를 전부 구문 분석(파싱)해야 하며, 구조적 편집을 할 때마다 그 전체를 다시 작성하게 됩니다. 평면화가 파괴하지 않는 것은 간접 참조를 통한 공유입니다. 10,000페이지 모두가 동일한 /Resources 딕셔너리 객체를 가리키는 평면 트리라도 중복 제거는 유지됩니다. 잃게 되는 것은 단지 해당 항목을 페이지에 남겨두지 않고 조상(ancestor) 노드가 제공하도록 허용하는 선택지일 뿐입니다
/Count가 거짓말을 할 때
/Count는 순전히 장부 기입을 위한 것입니다. 노드의 하위 트리에 있는 잎 페이지 수와 같아야 하며, 파일 형식 자체에서는 이를 강제하는 것이 없습니다. 야생에서 보이는 거짓 수치(lying counts)들의 대부분은 두 가지 손상 패턴에 해당합니다
첫 번째는 점진적 업데이트(incremental update)가 남긴 오래된 수치(stale count)입니다. 편집기가 페이지를 삽입하고 새 /Kids 및 업데이트된 /Count로 바로 위 부모를 다시 쓴 다음 이들을 파일 끝에 추가하면서 조상들은 전혀 건드리지 않는 경우입니다
% 원본 리비전
12 0 obj
<< /Type /Pages /Count 9 /Kids [13 0 R 14 0 R 15 0 R] >>
endobj
14 0 obj
<< /Type /Pages /Parent 12 0 R /Count 3
/Kids [50 0 R 51 0 R 52 0 R] >>
endobj
% 추가된 개정판: 가운데 분기에 한 페이지 삽입.
% 객체 14는 대체됨; 객체 12는 재작성되지 않음
14 0 obj
<< /Type /Pages /Parent 12 0 R /Count 4
/Kids [50 0 R 51 0 R 90 0 R 52 0 R] >>
endobj
이제 트리에는 잎(leaves)이 10개가 되었지만 루트에는 여전히 9개라고 쓰여 있습니다. 루트를 신뢰하는 뷰어는 페이지 카운터에 9페이지로 보고합니다. 페이지 이동 이진 검색(binary-search)에 내부 /Count를 사용하는 뷰어는 삽입 지점 이후의 모든 페이지에 대해 잘못된 인덱스를 계산하게 됩니다. 전체 순회(traversal)는 10개를 찾습니다. 하나의 파일, 세 가지 다른 답입니다
두 번째 패턴은 결코 맞을 수 없는 수치입니다. 음수, 하위 항목이 있는(populated) 노드에서의 0값 또는 터무니없이 큰 수치입니다. 이러한 수치들은 퍼징(fuzzing), 전송 오류(transmission damage) 및 간혹 편집기의 산술 버그에서 비롯됩니다. 이것들은 특히 할당(allocation) 시 /Count를 신뢰하는 코드에 위험합니다. -3이라는 /Count로부터 배열 크기를 정하는 것은 기껏해야 범위(range) 오류를 일으키고 20억이라는 /Count로 그 작업을 수행하는 것은 서비스 거부(DoS) 할당입니다. 파일 안의 여느 다른 수치와 마찬가지로 이 값은 신뢰할 수 없는 입력입니다
이 모든 것에 대해 파서(parsers)는 두 진영으로 나뉩니다. 엄격한(strict) 소비자(프리플라이트(preflight) 도구, PDF/A 검증기, 아카이빙 파이프라인)는 /Count를 순회(traversal) 결과와 비교하고 파일을 거부하거나 플래그를 지정합니다. 대화형 뷰어는 거의 보편적으로 관대합니다. 그들은 순회하고, 실제 개수를 파악하며 저장된 개수를 소리 없이 무시합니다. 이것이 바로 오래된 수치를 가진 파일이 어떤 자동화 워크플로 내부에서 더 엄격한 파서를 만날 때까지 여러 해 동안 불평 없이 유통될 수 있는 정확한 이유입니다. 라이브러리 코드를 위한 방어적인 중간 타협점은 /Count를 힌트(사전 할당 및 검증 후 하위 트리 건너뛰기에 유용)로 취급하되 순회 결과가 진실의 원천(source of truth)으로 남아 있도록 하는 것입니다
순회(traversal) 알고리즘 그 자체, 상속 조회(inheritance lookup) 규칙 및 카탈로그에서 잎으로의 이동에 대해 알아보려면 페이지 순서 해설서를 시작하십시오. 실제 고객 문서가 프로덕션(production) 코드에 도달할 때 이러한 실패 양상이 어떻게 나타나는지에 대해서는 뒤섞인 페이지 사건의 증상부터 근본 원인까지 추적한 페이지 순서 디버깅 사례 연구를 읽어보시기 바랍니다
HotPDF Component는 내부적으로 이 모든 사항을 처리합니다. 어떤 깊이의 중첩 트리든 순회하고 페이지 복사 또는 이동 시 상속된 속성을 해결하며 /Count를 신뢰하는 대신 실제 잎(leaf) 수에 대해 검증하므로, API의 페이지 인덱스는 항상 논리적인 페이지를 의미합니다