PDF Library for Delphi는 AddPageLabels로 페이지 레이블 범위를 기록하는데, v3.539.10부터 이 호출이 /PageLabels 넘버 트리가 /Kids 노드로 갈라져 있는 불러온 파일에서도 동작합니다. 새 범위를 넣기 전에 루트를 /Nums 리프 하나로 평탄화하므로, 레이블이 조용히 무시되는 대신 뷰어에 실제로 나타납니다. 전형적인 피해자는 조판 도구가 만든 책 스타일 PDF입니다. 앞부분은 로마 숫자, 본문은 아라비아 숫자, 부록은 A-1, A-2 레이블인데, 부록만 다시 레이블링하고 싶었던 건데 아무 변화도 없었던 경우입니다
PDF 페이지 레이블이란 무엇이고 어떻게 저장될까?
페이지 레이블은 뷰어가 물리적 페이지 인덱스 대신 페이지 박스에 보여 주는 문자열이고, ISO 32000-1 §12.4.2는 이를 카탈로그 키 /PageLabels 아래의 넘버 트리로 저장합니다. 각 키는 레이블링 범위를 시작하는 0 기반 페이지 인덱스이고, 각 값은 최대 세 엔트리를 갖는 페이지 레이블 딕셔너리입니다. 번호 스타일(D, R, r, A, a)의 /S, 접두어 문자열의 /P, 그리고 범위 내 첫 페이지의 숫자 값으로 기본값이 1인 /St입니다. 범위는 다음 키까지 이어지고, 스펙은 트리가 페이지 인덱스 0의 값을 갖도록 요구하므로 모든 페이지가 어떤 범위에든 속합니다
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 ... (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;
TPDFlib.AddPageLabels(Start, Style, Offset, Prefix)는 세 가지 규칙만 알면 인자를 그 딕셔너리에 따로 놀랄 게 없이 매핑합니다. Start는 라이브러리의 다른 페이지 인자처럼 1 기반이며 트리에는 Start - 1로 기록됩니다. Style은 0부터 5까지이며, 0은 접두어 전용, 1부터 5는 /S 값 D, R, r, A, a가 됩니다. 범위 밖의 값은 0을 반환하고 아무것도 건드리지 않습니다. Offset은 0보다 클 때만 /St가 되므로 0을 넘기면 그냥 키가 생략되고 뷰어는 기본값 1로 돌아갑니다. 페이지 레이블은 PDF 1.3에서 도입됐으므로 이 호출은 EnsureMinVersion('1.3', '/PageLabels')도 실행하는데, 저장 버전을 명시적으로 잠가 두지 않았다면 오래된 파일의 출력 버전을 올립니다
트리에 /Kids가 있으면 새 페이지 레이블이 사라지는 이유는?
새 레이블이 사라지는 이유는 ISO 32000-1 §7.9.7(표 37)이 넘버 트리의 루트에 /Kids나 /Nums 중 정확히 하나만 갖도록 요구하는데, 예전 NumTreeSet 헬퍼는 /Nums를 찾는 법만 알았기 때문입니다. 긴 문서를 내놓는 생산자들은 트리를 중간 노드들로 갈라서 각각에 /Limits 쌍을 두고 /Kids만 있는 루트에 매다는 경우가 많습니다. 옛 코드는 그 루트에서 /Nums를 찾지 못하고, 기존 /Kids 옆에 새로 하나 만들어 그곳에 새 범위를 넣었습니다. 결과는 서로 배타적인 진입점 두 개를 가진 루트였습니다. 뷰어들은 /Kids를 따라 내려가서 그 길 잃은 배열은 절대 보지 않고, 라이브러리 자체의 EnumNumTree도 /Kids를 먼저 검사하며, NumTreeLookup은 HasKids xor HasNums가 거짓인 노드를 거부합니다. 그런데도 AddPageLabels는 1을 반환했고 저장된 파일도 멀쩡히 열렸습니다. 가장 나쁜 부류의 실패입니다. 아무것도 불평하지 않고, 레이블만 그대로입니다
NumTreeSet의 수정은 무언가를 삽입하기 전에 루트를 리프로 바꿉니다. 루트에 /Kids가 있으면 EnumNumTree가 순서대로 모든 리프를 돌며 키-값 쌍을 수집하고, 그 목록으로 새 평탄한 /Nums 배열을 만든 다음, 평탄 배열을 붙이기 전에 /Kids, /Limits, 남아 있던 /Nums를 루트에서 밀어냅니다. /Limits를 버리는 건 미관 문제가 아닙니다. 표 37은 그 엔트리를 중간 노드와 리프 노드에만 허용하고 루트에는 절대 허용하지 않기 때문입니다. 그 시점부터 삽입은 하나의 배열에 대한 평범한 정렬 삽입이고, 기존 범위들은 원래 레이블 딕셔너리를 그대로 살아남습니다. 트레이드오프는 의도된 것입니다. 나중에 트리를 균형 잡힌 /Kids 노드로 다시 만들지는 않습니다. 페이지 레이블에는 이게 공짜입니다. 큰 레퍼런스 매뉴얼도 범위가 몇십 개를 넘는 일이 드물고, 어차피 대부분의 생산자는 리프 하나를 쓰기 때문입니다
// /PageLabels 루트가 /Kids를 쓰는 파일에서 부록 다시 레이블링
if Lib.LoadFromFile('vendor-manual.pdf', '') = 1 then
begin
WriteLn('Before: ', Lib.GetPageLabel(121)); // e.g. A-1
// 121페이지에서 시작하는 범위 교체: App-a, App-b ...
if Lib.AddPageLabels(121, 5, 1, 'App-') = 1 then
Lib.SaveToFile('vendor-manual-relabeled.pdf');
// 기존 로마 숫자와 10진수 범위는 평탄화된 리프에 그대로 있습니다
WriteLn('After: ', Lib.GetPageLabel(121)); // App-a
WriteLn('Front: ', Lib.GetPageLabel(2)); // ii, 그대로
end;
/Nums 배열이 키로 잘못 읽힐 수 있는 방법은?
/Nums 배열이 잘못 읽히는 경우는 코드가 요소를 하나씩 훑을 때입니다. 배열은 [key0 value0 key1 value1 ...]처럼 쌍이 번갈아 나오는 평평한 나열이고 키는 짝수 위치에만 있기 때문입니다. 옛 NumTreeSet 루프는 모든 요소를 숫자 타입인지 검사했으므로 우연히 숫자인 값이 키인 것처럼 비교됐고, less-than이 맞아떨어지면 삽입 지점이 홀수 인덱스로 잡혀 새 쌍이 기존 쌍 한가운데 박히고 뒤의 모든 쌍이 어긋났습니다. EnumNumTree도 같은 한 걸음 훑기를 썼습니다. 이제 둘 다 스트라이드 2로 쌍을 순회하며 X * 2에서 키를, X * 2 + 1에서 값을 읽고, 정확히 일치하는 키는 값을 교체한 뒤 Break로 빠져나옵니다. 공정히 말하면 페이지 레이블 값은 딕셔너리라 이 두 번째 버그는 /PageLabels 자체에서는 잘 발화하지 않았습니다. 그래도 잘못된 스트라이드로 읽는 넘버 트리 헬퍼는 값이 숫자인 순간 손상되는 코드고, 같은 패스에서 고쳐졌습니다
레이블 읽어 오기와 왕복 처리
TPDFlib.GetPageLabel(Page)는 1 기반 페이지의 레이블을 돌려주며, 알아 둘 만한 폴백이 두 가지 있습니다. /PageLabels 엔트리가 아예 없으면 10진수 페이지 번호를 반환하므로 호출자는 무조건 써도 됩니다. 트리는 있는데 그 페이지를 커버하는 범위가 없으면 빈 문자열을 반환하는데, 파일이 필수인 인덱스 0 엔트리를 빼먹었을 때 정확히 이렇게 됩니다. 참조 문서는 레이블이 올바르게 표시되려면 1페이지에서 시작하는 범위가 반드시 존재해야 한다고 말하고, 코드는 그 요구 사항을 드러내 보입니다. 문자 스타일은 스프레드시트 열이 아니라 스펙을 따릅니다. Z 다음은 AA이고 그다음은 BB로, 자릿수 올림 대신 문자를 반복합니다
var
P: Integer;
Data: WideString;
begin
// 뷰어가 페이지 박스에 보여 줄 내용 빠르게 감사
for P := 1 to Lib.PageCount do
WriteLn(P, ' -> ', Lib.GetPageLabel(P));
// 옵션 값 4는 레이블 범위만 PageLabelBegin 레코드로 내보냅니다
Data := Lib.ExportDocumentData(4);
// 임포트는 ClearPageLabels + AddPageLabels를 거쳐 재생합니다
Lib.ImportDocumentData(Data, 0);
end;
대량 편집용으로는 옵션 값 4의 ExportDocumentData가 모든 범위를 PageLabelNewIndex, PageLabelStart, PageLabelPrefix, PageLabelNumStyle 줄을 갖춘 PageLabelBegin 블록으로 기록하고, ImportDocumentData는 처음 만난 레이블 레코드를 전면 교체로 취급합니다. ClearPageLabels를 한 번 호출한 다음 각 레코드를 AddPageLabels로 흘려보냅니다. 덕분에 원본 파일이 /Kids 트리를 썼더라도 텍스트 왕복은 결정적으로 동작합니다. 지우기가 카탈로그 엔트리 전체를 제거하고 다시 만든 트리는 처음부터 리프 하나이기 때문입니다
이 수정이 여전히 보장하지 않는 것은?
평탄화는 일방향이고 발견한 순서를 믿습니다. EnumNumTree는 파일 순서대로 쌍을 수집하고, GetPageLabel은 키가 페이지 인덱스 이하인 마지막 범위를 적용합니다. 그래서 리프 순서가 뒤엉킨 외부 파일(§7.9.7이 금지하지만 실제로 돌아다닙니다)은 ClearPageLabels와 새 AddPageLabels 호출로 범위를 다시 만들기 전까지는 잘못된 레이블을 내놓을 수 있습니다. 레이블은 페이지 객체가 아니라 페이지 인덱스에 묶여 있으므로, 페이지 수나 순서를 바꾸는 모든 연산은 범위를 제자리에 그대로 둡니다. 오브젝트 번호를 보존한 채 페이지 교체 같은 제자리 교체는 수를 유지하므로 레이블도 정렬된 채 남지만, 인터리브 양면 스캔 정리 병합 같은 병합은 새 페이지 시퀀스를 만들어 내므로 새로 작성한 범위 세트가 어울립니다
여기서 설명한 페이지 레이블 호출, 넘버 트리 처리, 문서 데이터 export와 import는 모두 Delphi, C++Builder, Lazarus용 PDF Library for Delphi에 들어 있으며, AddPageLabels의 참조 항목이 스타일 값과 반환 코드를 문서화합니다