기술 문서

HotPDF를 사용하여 Delphi에서 기존 PDF에 북마크 추가하기

HotPDF는 Delphi에서 로드된 문서의 임의 중첩 깊이 상에 아웃라인 항목을 생성하는 AddLoadedOutline 함수를 통해 기존 PDF에 북마크를 추가하며, ResolveLoadedNamedDestinationAddLoadedNamedDestination 함수를 통해 네임드 데스티네이션(named destinations, 명명된 대상) 정보를 읽고 씁니다. 파일을 로드하고, 북마크 트리를 빌드한 후 SaveLoadedDocument를 호출하면, 이전에는 빈 화면이었던 PDF 뷰어의 사이드바 영역에 정상 동작하는 문서 목차(TOC)가 즉각 생성됩니다

이러한 제어 기능이 요구되는 실무 현업의 상황은 매우 흔합니다. 예컨대 십여 개의 계약서 문서를 하나의 검토용 패키지로 물리 병합하거나 3개의 제품 설명서들을 하나의 배포용 파일로 가로 스티칭 합성하는 작업을 수행했을 때, 결과물 자체는 구조적으로 아무 문제가 없습니다: 모든 페이지가 잘 보이고 폰트도 정상적으로 드로잉됩니다. 하지만 누군가 이 문서를 Acrobat Reader 등으로 열어 보았을 때 북마크 패널 정보가 텅 비어 있습니다. 목차 내비게이션 정보가 유실된 400페이지짜리 지면 문서는 형식적으로는 완성본이지만 실무적으로는 거의 활용 불가능합니다. HotPDF v2.347.0 이전 버전까지는 로드된 문서 상의 북마크 정보를 조회, 편집, 삭제하는 것만 가능할 뿐 신규 생성이 불가능했으나 이제 이 한계가 완벽히 극복되었습니다. 본 기사에서는 신규 추가된 북마크 관련 API 세트들을 설명하고, 단순 카운터 변수로 착각해 버그를 내기 쉬운 PDF 특수 구조 사양인 /Count 키의 작동 논리를 짚어 봅니다

북마크 아웃라인 트리의 구조 분석

PDF의 북마크 아웃라인 구조는 단순 일렬 리스트가 아닌 이중 연결 트리(doubly linked tree) 사전(dictionary) 구조를 띠고 있으며, 이 구조를 파악하면 API의 각 인수 성격을 이해할 수 있습니다. ISO 32000-1 §12.3.3 사양서에 의하면, 문서 카탈로그 객체는 /Outlines 루트를 지시하며, 이 루트는 /First/Last 키를 통해 첫 자식과 끝 자식 노드를 가리키고, 형제 노드들은 /Next/Prev로 사슬 체인처럼 엮이며, 각 하위 아웃라인 항목들은 /Parent 키를 통해 부모 노드로 역추적합니다. 각 항목들은 지면에 표기될 /Title 문자열과 클릭 시 뷰어를 해당 위치로 보낼 /Dest(대상 지표) 정보 등을 소유합니다

지면 이동을 처리하는 대상(Destinations) 정보는 크게 두 종류로 구분되며, ISO 32000-1 §12.3.2 규격은 이를 엄격히 다룹니다. 첫째는 명시적 대상(explicit destination)으로, [page /XYZ x y zoom]과 같이 페이지 객체 직접 참조 주소와 화면 보기 사양을 배열 형태로 기입하는 형태입니다. 둘째는 네임드 데스티네이션(named destination)으로, 직접 주소값 대신 임의의 이름 문자열을 기입해 두고 실 타겟 정보는 문서 카탈로그 하위의 /Names /Dests 이름 트리 구조 내에 적립해 두는 방식입니다. 이 간접 참조 설계 덕분에 수많은 링크들이 하나의 타겟 지표를 공유할 수 있으며, 실제 페이지가 이동하더라도 링크들을 일일이 수정하지 않고 해당 맵핑 데이터 하나만 교체해 유연히 제어할 수 있습니다. HotPDF는 북마크 생성 시 명시적 /XYZ 대상을 기록하며, 이와는 별도로 이름 기반 트리를 분석하고 확장할 수 있는 단독 API 기능들도 제공합니다

Delphi에서 기존 PDF에 북마크를 어떻게 추가합니까?

문서 LoadFromFile 호출 후 AddLoadedOutline(ParentIndex, DestPageIndex, Title, X, Y, Zoom) 함수를 호출하십시오: 최상위 대분류 항목으로 기입하려면 ParentIndex = -1을 대입하고, 특정 대분류 항목의 하위(sub-bookmark)로 중첩 기입하려면 해당 부모 항목의 0부터 시작하는 인덱스 번호를 대입하십시오. 이 함수는 문서에 /Outlines 루트가 누락되어 있으면 이를 자동 생성하고, 아웃라인 항목 사전을 구축하며, 0부터 시작하는 지정 DestPageIndex 페이지를 조준하는 /Dest [page /XYZ x y zoom] 배열 정보를 기입한 후 체인 리스트에 신규 항목을 연동시킵니다. 성공 시 0을 반환하며 DestPageIndex 수치가 유효 범위를 초과하는 등의 실패 요인 발생 시 -1을 반환합니다

순서 정렬과 관련된 한 가지 연동 특징이 있습니다: 새로 추가된 북마크 항목은 부모의 자식 체인 리스트 *맨 앞*(head)에 삽입되어 새로운 /First 항목으로 잡힙니다. 즉 "Chapter 1"을 추가한 후 연이어 "Chapter 2"를 최상위 항목으로 추가하면, 사이드바 뷰 상에서 Chapter 2가 Chapter 1보다 위에 위치하게 되며 기존 Chapter 1의 인덱스 번호는 1로 당겨집니다. 따라서 북마크 기입 연산을 수행할 때는 목차의 역순(역방향)으로 기입해 들어가거나, 부모 항목 기입 완료 직후 해당 부모가 인덱스 0 위치에 있을 때 하위 자식 노드들을 기입하는 순차 방식으로 구현해야 꼬이지 않습니다

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('merged-manual.pdf', '') > 0 then
    begin
      // Add chapters in reverse reading order: each new
      // top-level entry becomes /First (index 0).
      Pdf.AddLoadedOutline(-1, 40, 'Chapter 2: Configuration', 0, 792, 0);
      Pdf.AddLoadedOutline(-1, 0, 'Chapter 1: Installation', 0, 792, 0);
      // Chapter 1 is now top-level index 0; nest sections
      // under it (children also prepend, so reverse order).
      Pdf.AddLoadedOutline(0, 12, 'License activation', 0, 792, 0);
      Pdf.AddLoadedOutline(0, 3, 'System requirements', 0, 792, 0);
      Pdf.SaveLoadedDocument('merged-manual-toc.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

X, Y, 및 Zoom 인수들은 /XYZ 대상 지표 정보에 다이렉트로 매핑되며 모두 기본값은 0. PDF는 지면 좌측 하단을 원점(0,0)으로 사용하므로, Y = 792 수치는 표준 레터지 규격 지면의 최상단 스크롤 위치에 해당합니다. 또한 ISO 32000-1 사양에 따라 /XYZ 배열 슬롯 내의 0 수치는 뷰어 프로그램의 현재 확대 배율을 그대로 유지(retain)하도록 지시하므로 대부분의 화면 이동 요건에 부합합니다. 모든 작업은 메모리 내 객체 그래프 상에서 수행되며, SaveLoadedDocument를 호출하기 전까지는 실제 디스크 쓰기가 유발되지 않습니다. 이는 기존 PDF의 문서 제목, 저작자 정보 및 XMP 메타데이터 수정 기사에서 설명하는 로드-편집-저장 패턴과 완전히 동일합니다

북마크 대상 재수정: 페이지 이동 대 URI 외부 액션

기존 북마크 항목들을 파괴하고 다시 만들 필요 없이 대상 지표만 재정렬해 줄 수 있습니다. PDF 내부 구조 특성이 완전히 다르므로 두 가지 용도별 전용 API 호출 함수가 제공됩니다. SetLoadedOutlineDestination(Index, DestPageIndex, X, Y, Zoom)은 지정 Index를 가진 최상위 북마크의 /Dest 정보를 새 /XYZ 배열로 덮어씌웁니다. 여러 문서를 단순 병합한 후 각 북마크들이 여전히 과거 별개 파일 기준의 페이지 번호만 가리키고 있을 때 이를 한꺼번에 보정하기 좋은 도구입니다. 반면 SetLoadedOutlineURI(Index, URI)는 구조적으로 완전히 다른 연산을 수행합니다: 아웃라인 항목에 /S /URI 속성이 가산된 /A(Action) 사전을 탑재하여, 북마크 클릭 시 문서 내부 이동 대신 외부 웹페이지 링크 브라우저가 열리도록 구성합니다

// The merge shifted everything by 10 pages: repoint the
// third top-level bookmark at page 12 (zero-based 11).
Pdf.SetLoadedOutlineDestination(2, 11, 0, 792, 0);

// Turn the last entry into an external link.
Pdf.SetLoadedOutlineURI(3, 'https://www.loslab.com/');

Pdf.SaveLoadedDocument('retargeted.pdf');

북마크 트리를 설계할 때 이 두 작동 방식을 혼동해서는 안 됩니다. /Dest는 오직 문서 내부에서의 정렬 이동만을 관할하며, URI 액션은 지면 외부 공간으로 나갑니다. ISO 32000-1은 하나의 아웃라인 항목이 두 방식을 혼용하지 않고 둘 중 하나로만 작동할 것을 전제합니다. 따라서 SetLoadedOutlineURI 조작은 이미 기존 페이지 이동 위치가 잡혀 있는 목차 제목에 대입하기보다는, 트리 맨 하단에 독립 배치되는 "제품 홈페이지 바로가기" 또는 "버그 신고" 등 단독 링크 용도 항목에 제한적으로 적용하는 것이 바람직합니다. 여기에 기입되는 URI 액션 구조는 일반 지면에 생성해 얹는 링크 주석의 /S /URI 사양과 동일하며, 자세한 정보는 HotPDF를 활용한 PDF 문서 링크 주석 생성 가이드 기사에서 설명합니다

로드된 PDF 상에서 네임드 데스티네이션의 동작 구조

네임드 데스티네이션(named destinations)은 ISO 32000-1 §12.3.2.3에서 정의하는 일종의 앵커(anchor) 위치 보존 장치입니다: 각 링크 개체 내부에 매번 물리 페이지 직접 주소를 기입하는 대신, 카탈로그 하위의 /Names /Dests 이름 트리 구조 내에 앵커 이름-이동 주소 쌍을 맵핑 등록해 두고 링크에서는 이름 문자열만을 빌려다 쓰는 방식입니다. ResolveLoadedNamedDestination(Name)은 해당 이름 트리를 추적해 매칭되는 0부터 시작하는 페이지 번호를 반환하며, 이름이 유효하지 않으면 -1을 반환합니다. 이 추적기 기능은 규격서가 정의하는 2가지 배치 형식을 모두 해독할 수 있습니다: 즉 순수 주소 배열인 [page /XYZ x y zoom] 형식과, /D 키 하위에 주소 배열을 품고 있는 사전(dictionary) 형식 모두 안전하게 역추적합니다

반대로 AddLoadedNamedDestination(Name, DestPageIndex, X, Y)는 신규 앵커 정보를 /Names /Dests 이름 트리 상에 등록해 주며, 관련 트리 구조가 문서 상에 누락되어 있으면 자동으로 신규 구축하고 연산 성공 시 True를 반환합니다. 이 두 함수 조합을 통해 타사 툴이 문서에 남긴 앵커 주소 데이터를 그대로 연동해 이어받거나, 타사 뷰어 및 다운스트림 PDF 조작 툴에서 추적해야 할 신규 앵커를 추가 기입할 수 있습니다

var
  PageIdx: Integer;
begin
  // Honor an anchor another tool wrote into /Names /Dests.
  PageIdx := Pdf.ResolveLoadedNamedDestination('chapter.3.figures');
  if PageIdx >= 0 then
    Pdf.AddLoadedOutline(-1, PageIdx, 'Chapter 3: Figures', 0, 792, 0);

  // Publish a new anchor for other links to target.
  if Pdf.AddLoadedNamedDestination('appendix.glossary', 42, 0, 792) then
    Pdf.SaveLoadedDocument('anchored.pdf');
end;

기술 제약 조건: HotPDF가 북마크나 네임드 앵커 등록 시 기입하는 대상 데이터 유형은 /XYZ 사양으로 고정됩니다. 규격서가 명시하는 /Fit, /FitH, /FitB 등의 특수 뷰 설정들은 이 함수들에서 직접 생성 배출되지는 않으나, 줌 값을 0으로 지정한 /XYZ 설정만으로도 "지정 영역으로 화면을 넘기되 사용자가 보던 배율은 유지하기" 요건을 깔끔하게 대체 충족합니다. 또한 특정 북마크 타이틀 텍스트를 대입해 타겟 페이지 번호를 찾아내는 편의 메소드인 GetLoadedBookmarkPageIndex(Title)은 아웃라인 트리의 대분류 최상위 노드들 위주로 우선 탐색하므로, 깊게 숨겨진 자식 노드보다는 신규 삽입 완료한 대분류 챕터 노드 유효성 점검에 사용하는 것이 알맞습니다

/Count 의미론의 함정: 단순 직계 카운터가 아닌 자손 노드 총계 수치

아웃라인 루트 노드의 /Count 키 데이터는 대다수의 개발자들의 예상과 달리 작동하므로, 자칫 잘못 연동하면 데이터 오염 버그로 오인될 수 있는 회귀 실패를 유발합니다. ISO 32000-1 §12.3.3 규격에 의하면, /Count 값은 최상위 항목 개수가 아니라 모든 중첩 레벨에서 *눈에 보이게 펼쳐져 있는 모든 자손(descendants) 항목들의 합계*입니다. 개별 아웃라인 항목 상에서는 음양 부호 정보도 의미를 갖습니다: 양수(positive) 값은 해당 북마크 항목이 펼쳐진(open) 상태로 화면에 보이는 자식 수가 N개임을 나타내며, 음수(negative) 값은 접혀 있는(collapsed) 상태로 그 내부에 감춰진 자식 총합이 |N|개임을 의미합니다

이 설계 사양은 HotPDF 로드 문서용 북마크 API 개발 검증 과정에서 확인된 사실입니다. 대분류 북마크 3개(이 중 하나가 하위 자식 노드를 포함함)가 기입되어 있던 테스트 문서에서 최상위 항목 하나를 삭제한 후 아웃라인 루트의 /Count 정보를 읽는 GetLoadedOutlineCount를 조회했더니, 예상했던 2 대신 1이 출력되었습니다. 이는 데이터 버그가 아닙니다: 애초에 최초 수치였던 3 자체가 대분류 개수 3개가 아니었으며, 보정 연산은 대분류 체인을 돌며 각 노드 정보와 양수로 기입된 하위 /Count 합계치를 연산해 도출하고 접혀 있는 노드들의 하위 자손 데이터는 제외시키도록 정교하게 구동되기 때문입니다. 개발자가 얻어야 할 교훈은 명확합니다: 북마크 구조를 추가/삭제한 후 /Count 단순 차이값(delta) 검출 방식으로 단위 테스트 단언(assert)을 짜지 마십시오. 눈에 펼쳐진 노드 수에 따라 비선형적으로 변하는 값이므로, 실 타이틀 텍스트 조회 방식으로 북마크가 안전하게 기입되었는지 점검하는 방식을 권장합니다

// After edits, verify by title lookup, not by /Count deltas.
if Pdf.GetLoadedBookmarkPageIndex('Chapter 1: Installation') = 0 then
  ShowMessage('Outline verified');

로드 문서용 도구 세트와의 연동 흐름

북마크 및 네임드 데스티네이션 제어 기능의 추가로 HotPDF 로드 문서용 API의 완성도가 한층 단단해졌습니다: 이미 제공되던 메타데이터 정보 편집, 링크 주석 추가, XFDF 파일 교환 기반의 주석 데이터 연동 가져오기/내보내기 등의 일관된 로드-편집-저장 라이프사이클에 북마크 제어 공정까지 유연하게 결합되었습니다. LoadFromFile 호출, *Loaded* 계열 편집 함수 다중 가공, 최종 SaveLoadedDocument로 종결되는 단순하고 직관적인 흐름 덕분에, 기존 문서 병합이나 도장 날인 파이프라인의 최종 저장 단계 직전에 북마크 생성 로직을 얹어 처리하기 매우 편리합니다

여기에 소개된 AddLoadedOutline, SetLoadedOutlineDestination, SetLoadedOutlineURI, ResolveLoadedNamedDestination, AddLoadedNamedDestinationGetLoadedBookmarkPageIndex 등의 API는 별도의 외부 종속성이나 추가 뷰어 실행 환경 없이, Delphi 및 C++Builder용 HotPDF Component의 표준 내장 패키지로 완전히 제공됩니다