벤더 매뉴얼에 새 레이블 범위를 넣어 부록이 A-1, A-2 대신 App-a, App-b로 읽히게 하고, 저장하고, 파일을 다시 열었더니 페이지 박스는 여전히 A-1입니다. 카탈로그를 텍스트 에디터로 열면 /PageLabels 딕셔너리가 이유를 보여 줍니다. 생산자에게 물려받은 /Kids 배열이 있고, 그 바로 옆에 당신의 범위를 담은 새 /Nums 배열이 붙어 있는 것입니다. ISO 32000-1은 넘버 트리 루트에 두 키 중 하나만 허용하고, 루트에서 /Kids를 발견한 리더는 그 안으로 내려간 뒤 더 이상 보지 않습니다
PDF가 페이지 레이블 범위를 저장하는 방식
페이지 레이블은 뷰어가 물리적 페이지 인덱스 대신 페이지 박스에 보여 주는 문자열이고, ISO 32000-1 §12.4.2는 이를 카탈로그 키 /PageLabels 아래 넘버 트리에 둡니다. 각 키는 레이블링 범위가 시작하는 0 기반 페이지 인덱스이고, 각 값은 최대 세 엔트리를 갖는 레이블 딕셔너리입니다. 번호 스타일의 /S(D, R, r, A, a), 접두어 문자열의 /P, 범위 첫 페이지의 번호를 뜻하며 기본값 1인 /St입니다. 범위는 다음 키까지 이어집니다. 스펙은 페이지 인덱스 0의 엔트리도 요구하므로 문서의 모든 페이지가 어떤 범위 안에 속합니다
PDF Library for Delphi에서 기록기는 TPDFlib.AddPageLabels(Start, Style, Offset, Prefix)이며, 외워 둘 만한 세 규칙으로 그 딕셔너리에 대응됩니다. Start는 라이브러리의 다른 페이지 인자처럼 1 기반이고 트리에는 Start - 1로 들어갑니다. Style은 0부터 5까지입니다. 0은 /S를 아예 쓰지 않아 레이블이 접두어만 되고(Cover라고 쓴 한 페이지에 유용), 1부터 5는 D, R, r, A, a가 됩니다. 0..5 밖의 스타일은 호출이 0을 반환하게 만들고 문서는 그대로 둡니다. Offset은 0보다 클 때만 /St가 됩니다. 0을 넘기면 키가 생략되는데, 모든 리더는 이를 1로 해석합니다
조판 도구가 만든 전형적인 책에는 세 범위가 필요합니다. 앞부분의 소문자 로마 숫자, 본문의 아라비아 숫자, 접두어가 붙은 부록입니다
uses
PDFlibrary;
var
Lib: TPDFlib;
begin
Lib := TPDFlib.Create;
try
if Lib.LoadFromFile('handbook.pdf', '') <> 1 then
Exit;
// 1-4페이지: i, ii, iii, iv (소문자 로마 숫자)
Lib.AddPageLabels(1, 3, 1, '');
// 5-120페이지: 1, 2, 3 ... (1부터 다시 시작하는 10진수)
Lib.AddPageLabels(5, 1, 1, '');
// 121페이지부터: A-1, A-2 ... (접두어가 붙은 10진수)
Lib.AddPageLabels(121, 1, 1, 'A-');
WriteLn(Lib.GetPageLabel(5)); // 1
WriteLn(Lib.GetPageLabel(122)); // A-2
Lib.SaveToFile('handbook-labeled.pdf');
finally
Lib.Free;
end;
end;
페이지 레이블은 PDF 1.3에서 왔으므로 각 호출은 문서의 출력 버전을 최소 1.3으로 올려 달라고도 요청합니다. LockSaveVersion으로 버전을 잠가 둔 경우 이 요청은 무시되고 잠금이 유지됩니다. 라이브러리는 의도적으로 고른 버전을 덮어쓰지 않기 때문입니다. 잠근다면 레이블을 쓰는 작업에서는 1.3 이상으로 잠그세요
루트에 /Kids가 있으면 새 범위는 왜 사라지는가?
범위가 사라지는 이유는 ISO 32000-1 §7.9.7(표 37)이 넘버 트리 루트에 /Kids나 /Nums 중 하나만 허용하고, 리더는 /Kids가 있으면 그쪽을 고르기 때문입니다. 긴 문서를 내놓는 생산자들은 트리를 각각 /Limits 쌍을 갖는 중간 노드들로 갈라 /Kids만 있는 루트에 매다는 경우가 많습니다. /Nums만 아는 삽입 루틴은 그 루트에서 배열을 찾지 못하고, 기존 /Kids 옆에 새 배열을 만들어 새 범위를 넣습니다. 쓰기는 성공하고 파일도 여전히 파싱됩니다. 그리고 모든 뷰어는 /Kids를 따라 내려가 생산자의 원래 세 범위를 볼 뿐, 길 잃은 배열은 건드리지 않습니다. 도입부의 벤더 매뉴얼 증상이 정확히 이것입니다
출구는 루트를 두 번째 진입점이 자라날 수 있는 컨테이너로 취급하는 것을 멈추는 것입니다. AddPageLabels는 루트에서 /Kids를 발견하면 먼저 기존 트리의 모든 리프를 왼쪽에서 오른쪽으로 훑으며 각 키와 레이블 딕셔너리를 수집하고, 그 목록으로 평평한 /Nums 배열 하나를 만듭니다. 평탄 배열을 붙이기 전에 /Kids, /Limits, 남아 있던 /Nums를 루트에서 제거하고, 그 후에야 새 범위가 평범한 정렬 삽입으로 들어갑니다. /Limits 제거도 중요합니다. 표 37은 이 엔트리를 중간 노드와 리프에만 허용하므로, 이제 /Nums를 담고 있더라도 그것을 유지하는 루트는 기형입니다
놓치기 쉬운 부수 효과가 하나 있습니다. 어떤 이전 도구의 성급한 삽입 때문에 파일이 이미 두 키를 루트에 두고 도착했다면, 리프 훑기는 뷰어와 마찬가지로 /Kids를 따르므로 길 잃은 배열의 범위는 넘겨지지 않습니다. 뷰어도 그 범위를 보여 주지 않았으므로, 편집 뒤의 레이블은 리더가 이미 보던 것과 일치합니다
평탄화된 트리를 나중에 균형 잡힌 /Kids 노드로 다시 만들지 않는데, 이 특정 트리에는 그것이 옳은 판단이라고 생각합니다. 균형화는 수천 개의 대상이나 임베디드 파일을 가진 이름 트리에서 빛을 냅니다. 페이지 레이블 트리는 레이블링 범위마다 항목 하나이고, 큰 레퍼런스 매뉴얼도 몇십 개를 넘는 일이 드물며, 굳이 갈라놓는 게 아닌 이상 대부분의 생산자가 쓰는 것도 리프 하나입니다. 그렇게 작은 트리에서 아무도 측정할 수 없는 조회 이득을 위해, 모든 저장 때 잘못될 수 있는 코드를 추가하는 셈이 됩니다
// /PageLabels 루트가 /Kids를 쓰는 파일에서 부록 다시 레이블링
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
WriteLn('Before: ', Lib.GetPageLabel(121)); // e.g. A-1
// 기존 범위와 같은 시작 페이지이므로 그 값이 교체됨
if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
begin
WriteLn('After: ', Lib.GetPageLabel(121)); // App-a
WriteLn('Front: ', Lib.GetPageLabel(2)); // ii, 변경 없음
Lib.SaveToFile('vendor-manual-relabeled.pdf');
end;
end;
새 범위가 기존 키와 같은 페이지 인덱스에서 시작하기 때문에, 삽입은 페이지 인덱스 120에 두 번째 항목을 추가하는 대신 그 키의 딕셔너리를 교체합니다. 생산자의 리프에 있던 로마 숫자와 10진수 범위는 /St 값을 포함해 원래 딕셔너리 그대로 살아 나옵니다
리프 훑기는 기형적인 트리도 견뎌야 합니다. 자기 조상 중 하나를 다시 가리키는 /Kids 항목, 두 갈래가 공유하는 리프는 손상된 파이프라인의 파일에서 등장합니다. 순회는 이미 방문한 노드를 기억하므로 순환은 한 번 걷고 두 번 다시 걷지 않으며, 공유된 리프는 범위를 한 번만 기여합니다. 이름 트리에 대한 같은 주제는 이름 트리 순환, 잘못된 /Limits, 큰 리프 처리에서 다룹니다
/Nums 배열을 쌍 단위로 훑기
/Nums 배열은 [key0 value0 key1 value1 ...]처럼 항목이 번갈아 나오는 평평한 나열이고 키는 짝수 위치에만 있습니다. 이 배치는 고전적인 실수를 부릅니다. 한 번에 한 요소씩 전진하며 "이것이 숫자인가?"를 묻는 루프는 값이 우연히 숫자일 때마다 그 값을 키인 것처럼 비교합니다. 그 비교가 less-than에서 이기면 삽입 지점이 홀수 인덱스에 잡히고, 새 쌍이 기존 쌍 한가운데 박히며, 그 뒤의 모든 키와 값이 자리를 바꿉니다
페이지 레이블 값은 딕셔너리이므로 이 버그는 /PageLabels 자체에서는 숨어 있습니다. 같은 헬퍼를 값이 평범한 숫자인 넘버 트리에 재사용하는 날에야 드러나고, 배열 문법의 어디에도 어떤 종류를 쥐고 있는지 알려 주는 표시는 없습니다. PDF Library for Delphi는 리프 훑기와 삽입 모두에서 인덱스 X * 2에서 키를, X * 2 + 1에서 값을 읽고, 정확히 일치하는 키는 값을 교체한 뒤 검색을 멈춥니다. 원시 객체를 상대로 자기 넘버 트리 코드를 쓴다면 무엇보다 먼저 이 스트라이드를 베껴 가세요
GetPageLabel로 레이블 읽어 오기
TPDFlib.GetPageLabel(Page)는 1 기반 페이지의 레이블을 반환하며, 두 폴백은 의도적으로 다르게 동작합니다. /PageLabels 엔트리가 아예 없는 문서는 10진수 페이지 번호를 반환하므로, 호출자는 표시 용도라면 이 함수를 무조건 써도 됩니다. 트리는 있는데 요청된 페이지를 커버하는 범위가 없는 문서는 빈 문자열을 반환합니다. 두 번째 사례는 파일이 페이지 인덱스 0의 필수 엔트리를 건너뛸 때 나옵니다. 스펙의 의미에서 앞 몇 페이지는 레이블이 없는 것이고, 빈 문자열은 지어낸 숫자보다 그 사실을 정직하게 보고합니다. 레이블 집합은 항상 1페이지에서 시작하세요
문자 스타일은 스프레드시트 열이 아니라 PDF 스펙을 따릅니다. Z 뒤에는 AA, 그다음 BB, 그다음 CC가 옵니다. 자릿수 올림 대신 문자를 반복하므로 A 범위의 53페이지는 AAA입니다. 로마 숫자는 R에서는 대문자로, r에서는 소문자로 만들어지고, 접두어는 두 경우 모두 그대로 앞에 붙습니다
var
P: Integer;
Data: WideString;
begin
// 뷰어가 페이지 박스에 보여 줄 내용 빠르게 감사
for P := 1 to Lib.PageCount do
WriteLn(P, ' -> ', Lib.GetPageLabel(P));
// 옵션 값 4는 레이블 범위만 내보내며, 범위마다 PageLabelBegin
// 블록 하나가 붙습니다
Data := Lib.ExportDocumentData(4);
// ... 텍스트를 편집하거나 다른 파일로 옮기는 자리 ...
// 첫 레이블 레코드가 기존 범위를 지우고, 이어서 각 레코드가
// AddPageLabels로 재생됩니다; 적용된 레코드 수를 반환
WriteLn(Lib.ImportDocumentData(Data, 4), ' label ranges applied');
end;
대량 편집에는 옵션 값 4의 ExportDocumentData가 각 범위를 PageLabelBegin 블록과 PageLabelNewIndex(1 기반 시작 페이지), PageLabelStart, 선택적 PageLabelPrefix, LowercaseRomanNumerals와 DecimalArabicNumerals 같은 이름을 쓰는 PageLabelNumStyle 줄로 기록합니다. ImportDocumentData는 처음 만난 레이블 레코드를 전면 교체로 취급합니다. ClearPageLabels를 한 번 호출해 카탈로그에서 /PageLabels 엔트리 전체를 지운 다음, 모든 레코드를 AddPageLabels로 흘려보냅니다. 다시 만들어진 트리는 처음부터 리프 하나이므로, /Kids 형태의 원본을 거친 텍스트 왕복도 다시 레이블링 경로가 내놓는 것과 같은 평평한 형태로 돌아옵니다
다시 레이블링한 뒤의 리프 순서와 페이지 이동
평탄화는 발견한 순서 그대로 범위를 유지합니다. §7.9.7은 키가 오름차순이기를 요구하지만 리프 순서가 엉킨 파일도 실제로 돌아다니고, GetPageLabel은 그 순서에서 키가 페이지 인덱스 이하인 마지막 범위를 적용합니다. 범위를 [0 roman, 120 A-, 4 decimal]로 선언한 파일, 즉 10진수 범위가 1에서 시작하는 파일에서는 122페이지가 10진수 범위로 풀려 기대했던 A-2 대신 118이 읽힙니다. 위의 페이지별 감사에서 그런 레이블이 보이면 AddPageLabels 호출을 한 번 더 얹어 덮지 마세요. ClearPageLabels를 호출하고 전체 집합을 오름차순으로 다시 쓰는 편이, 어느 범위가 이기는지 추론하는 것보다 네 번의 호출이 더 쌉니다
레이블은 페이지 객체가 아니라 페이지 인덱스에 묶여 있습니다. 페이지 수나 순서를 바꾸는 모든 연산은 범위를 정확히 제자리에 두고, 그 인덱스에 앉게 된 페이지를 가리키게 합니다. 오브젝트 번호를 보존한 채 페이지 교체 같은 제자리 교체는 수를 유지하므로 레이블 정렬도 유지됩니다. 인터리브 양면 스캔 정리 병합 같은 병합은 새 페이지 시퀀스를 만들어 내므로, 그 출력은 병합 뒤에 1페이지부터 시작하는 새로 쓴 범위 세트를 받아야 합니다
여기서 보인 레이블 호출과 /Kids 평탄화, 문서 데이터 내보내기와 가져오기는 Delphi, C++Builder, Lazarus를 대상으로 하는 PDF Library for Delphi의 일부이며, AddPageLabels의 참조 항목이 스타일 값과 반환 코드를 한 표에 정리합니다