기술 문서

PDF 메타데이터, 개요 및 주석 설명

같은 PDF가 저자를 둘 보고할 수 있습니다. 데스크톱 뷰어의 속성 대화상자는 한 이름을 보여 주고, XMP를 색인하는 자산 관리 시스템은 다른 이름을 보여 주며, 두 프로그램 모두 파일을 올바르게 읽고 있는 것입니다. PDF는 문서 메타데이터를 두 번 저장하고, 북마크와 링크, 노트는 페이지 콘텐츠와는 전혀 얽히지 않는 구조에 두므로, 모든 페이지가 완벽히 렌더링되는 가운데 그중 무엇이든 틀릴 수 있습니다

그 밑의 객체 모델(간접 객체, 교차 참조 테이블, 트레일러)은 PDF 문서 구조 워크스루에서 다룹니다. 여기서는 문서 카탈로그에 매달린 것들에 초점을 둡니다

카탈로그의 네 항목, 네 개의 독립적인 서브시스템

아래에서 다룰 모든 것은 문서 카탈로그, 즉 트레일러의 /Root 항목이 가리키는 사전에서 도달합니다(ISO 32000-1 §7.7.2). 그 모든 것을 연결한 카탈로그는 이렇게 생겼습니다

1 0 obj
<< /Type /Catalog
   /Pages 2 0 R
   /Outlines 3 0 R
   /Names << /EmbeddedFiles 4 0 R /Dests 6 0 R >>
   /Metadata 5 0 R
>>
endobj
PDF 문서 카탈로그가 페이지 트리, 개요 루트, 이름 사전, XMP 메타데이터 스트림을 네 개의 선택적 서브시스템으로 연결하는 PDF 다이어그램
문서 카탈로그는 네 개의 독립적인 서브시스템을 연결하고 각자의 항목으로 도달합니다. 페이지는 정상적으로 렌더링되는 가운데 그중 무엇이든 없어도 됩니다

/Pages가 보이는 문서이고, 카탈로그가 요구하는 넷 중 유일한 것입니다. /Outlines는 북마크 트리로, /Metadata는 XMP 스트림으로, /Names는 이름 사전으로 이어지며, 그 사전은 /EmbeddedFiles 아래에 포함 파일을, /Dests 아래에 이름 붙은 대상들을 둡니다. 선택적인 셋을 하나도 찾지 못한 리더도 모든 페이지는 보여 주므로, 페이지만 이해하는 도구들을 거칠 때 이 계층이 먼저 무너지는 이유입니다. 페이지 객체 안의 그 어떤 것도 개요나 /Dests 트리, XMP 스트림을 거슬러 가리키지 않습니다. 페이지만 새 파일로 복사하면 셋 모두 뒤에 남습니다

PDF는 왜 메타데이터 저장소를 둘 나르나요?

PDF가 메타데이터 저장소를 둘 나르는 이유는 형식이 첫째를 폐기하지 않은 채 둘째를 키웠기 때문입니다. 원래 메커니즘은 트레일러의 /Info가 참조하는 문서 정보 사전(ISO 32000-1 §14.3.3, Table 317)입니다. /Title, /Author, /Subject, /Keywords, /Creator, /Producer의 평평한 텍스트 항목에 /CreationDate, /ModDate, /Trapped가 더해집니다. ISO 32000-2는 두 날짜 항목을 제외한 사전 전체를 폐기 예정으로 표기하고 작성자들을 XMP 메타데이터 스트림 쪽으로 돌립니다

PDF Info 사전과 XMP 메타데이터 스트림, 겹치는 필드를 나르는 두 저장소가 XMP를 참값의 원천으로 삼아 동기화되어야 함을 비교하는 PDF 다이어그램
Info 사전과 XMP 스트림은 같은 질문에 나란히 답합니다. 한 세트의 값에서 둘을 다시 만들어 동기화를 유지하세요

XMP(ISO 32000-1 §14.3.2)는 RDF로 쓰인 XML 문서로, 사전이 /Type /Metadata /Subtype /XML을 운반하는 스트림에 저장됩니다. Info 사전과 달리 XMP 패킷은 PDF 문법을 전혀 모르는 도구들이 찾고 파싱하도록 설계되어 있습니다

5 0 obj
<< /Type /Metadata /Subtype /XML /Length 1235 >>
stream
<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
  <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
    <rdf:Description rdf:about=""
        xmlns:dc="http://purl.org/dc/elements/1.1/"
        xmlns:xmp="http://ns.adobe.com/xap/1.0/"
        xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
      <dc:title><rdf:Alt><rdf:li xml:lang="x-default">Quarterly Report</rdf:li></rdf:Alt></dc:title>
      <dc:creator><rdf:Seq><rdf:li>A. Author</rdf:li></rdf:Seq></dc:creator>
      <xmp:CreateDate>2026-06-16T10:46:27+08:00</xmp:CreateDate>
      <xmp:CreatorTool>Reporting Service 4.2</xmp:CreatorTool>
      <pdf:Producer>losLab PDF Library</pdf:Producer>
    </rdf:Description>
  </rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>
endstream
endobj

그 블록의 세 디테일이 패킷이 실제 도구들을 살아남는지를 결정합니다. xpacket 처리 명령이 패킷을 감싸서 스캐너가 임의의 바이트 스트림 안에서 그것을 찾을 수 있게 합니다. 닫는 <?xpacket end="w"?>를 빼도 파일은 여전히 열리지만 스캐너는 끝 표식을 잃습니다. 값 타입도 중요합니다. dc:title은 언어 대안이므로 rdf:Alt가 필요하고, dc:creator는 순서 있는 목록이므로 rdf:Seq가 필요합니다. 어느 쪽이든 맨몸 텍스트 노드로 쓰는 것은 흔한 실수입니다. 다만 접두사는 관례일 뿐입니다. 파서는 네임스페이스 URI로 판단하므로, 잘못된 URI에 묶인 dc:는 완전히 다른 속성입니다

Info 키와 XMP 쪽 짝은 다음처럼 맞습니다. PDF/A-1(ISO 19005-1 §6.7.3)은 이 짝을 적합성 규칙으로 밝힙니다. 두 저장소가 모두 있을 때 값들은 동등해야 합니다

Info 사전XMP 속성
/Titledc:title (rdf:Alt)
/Authordc:creator (rdf:Seq)
/Subjectdc:description (rdf:Alt)
/Keywordspdf:Keywords
/Creatorxmp:CreatorTool
/Producerpdf:Producer
/CreationDate, /ModDatexmp:CreateDate, xmp:ModifyDate

두 저장소가 어긋나는 곳은 손 편집과 병합입니다. 병합은 /Info를 한 입력에서, XMP 패킷을 다른 입력에서 물려받을 수 있습니다. 더 안전한 규칙은 XMP를 참값의 원천으로 다루고, 오래된 소비자들을 위해 /Info를 유지하며, 어느 쪽이든 바뀔 때 한 세트의 값에서 둘을 다시 만드는 것입니다

한 세트의 속성에서 두 저장소 쓰기

HotPDF는 두 저장소를 같은 문서 속성들에서 만듭니다. Title, Author, Subject, Keywords는 /Info로 가고, EnableXMPMetadata가 True면 EndDoc이 그 같은 값들에서 XMP 패킷도 씁니다. dc:title은 rdf:Alt로, dc:creator는 rdf:Seq로 삼아 카탈로그의 /Metadata에서 링크합니다. EnableXMPMetadata의 기본값은 False이고, 손을 떠나는 모든 것에 대해 켜 둘 가치가 있습니다. /Info만 있는 파일은 XMP만 읽는 모든 도구에 보이지 않습니다. 추가 스키마가 필요하면 CustomXMP가 생성된 패킷을 여러분 텍스트로 바꿉니다

uses
  System.SysUtils, Vcl.Graphics, HPDFDoc;

procedure BuildReport(const FileName: string);
var
  PDF: THotPDF;
  Chapter: THPDFDocOutlineObject;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    PDF.FileName := FileName;
    // 한 세트의 값이 /Info와 XMP 패킷을 함께 먹입니다
    PDF.Title := 'Quarterly Report';
    PDF.Author := 'A. Author';
    PDF.Subject := 'Results for the third quarter';
    PDF.Keywords := 'quarterly, results';
    PDF.EnableXMPMetadata := True;
    PDF.BeginDoc;

    PDF.CurrentPage.SetFont('Arial', [fsBold], 20);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Chapter 1: Results');
    Chapter := PDF.OutlineRoot.AddChild('Chapter 1: Results', 0, 50);
    Chapter.Opened := True;   // 양수 /Count로 저장됩니다

    PDF.AddPage;
    PDF.CurrentPage.SetFont('Arial', [fsBold], 16);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Introduction');
    Chapter.AddChild('Introduction', 0, 50);

    PDF.AddPage;
    PDF.CurrentPage.SetFont('Arial', [fsBold], 16);
    PDF.CurrentPage.TextOut(50, 50, 0, 'Methodology');
    Chapter.AddChild('Methodology', 0, 50);

    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

기존 파일의 편집은 다릅니다. 적재된 문서에서 SetLoadedTitle, SetLoadedAuthor, SetLoadedCreator와 그 형제들은 Info 사전 항목을 쓰고, XMP 스트림은 SetLoadedXMPMetadata로 교체하는 별개 객체입니다. 한쪽 호출로만 저자를 바꾸면 SaveLoadedDocument가 도입부의 두 저자 파일을 정확히 만들어 냅니다

책갈피 패널 뒤의 개요 트리

북마크 패널은 이중 연결 사전 트리, 즉 문서 개요(ISO 32000-1 §12.3.3, Tables 152와 153)에서 재구성됩니다. 파일 어디에도 북마크의 배열은 존재하지 않습니다. 뷰어는 카탈로그의 /Outlines에서 참조를 따라가 패널을 재구성합니다

뷰어의 북마크 패널을 재구성하는 Parent, First, Last, Prev, Next, Count 링크를 보여 주는 PDF 개요 트리 다이어그램
북마크 패널은 Parent, First, Last, Prev, Next, Count로 꿰어진 이중 연결 개요 트리에서 재구성됩니다. 낡은 링크 하나가 오류 없이 분기 하나를 통째로 숨깁니다
8 0 obj                                    % 개요 루트
<< /Type /Outlines /Count 3 /First 9 0 R /Last 9 0 R >>
endobj
9 0 obj                                    % 최상위: 챕터 하나
<< /Title (Chapter 1: Results)
   /Parent 8 0 R /Count 2
   /First 12 0 R /Last 15 0 R
   /Dest [3 0 R /XYZ 0 792 0] >>
endobj
12 0 obj                                   % 첫 자식
<< /Title (Introduction)
   /Parent 9 0 R /Next 15 0 R
   /Dest [4 0 R /XYZ 72 720 0] >>
endobj
15 0 obj                                   % 둘째 자식, 마지막 형제
<< /Title (Methodology)
   /Parent 9 0 R /Prev 12 0 R
   /Dest [5 0 R /Fit] >>
endobj

불변식은 링크에서 곧장 읽힙니다. 모든 항목은 자기 /Parent를 거슬러 가리킵니다. 형제들은 /Prev와 /Next로 사슬을 이루고, 첫 자식에는 /Prev가, 마지막에는 /Next가 없습니다. 부모는 /First와 /Last로 두 끝을 짚고, 그 사이의 것들은 형제 순회의 한 걸음으로만 존재합니다. 링크 하나를 끊으면 뷰어들은 여전히 도달하는 것을 보여 줍니다. 낡은 /Next는 챕터를 중간에 끊고, 사슬의 끝과 어긋나는 /Last는 뷰어마다 다른 트리를 그리게 만듭니다

/Count는 패널 상태를 저장하기 때문에 사람들을 놀라게 합니다. 루트에서는 모든 수준에서 보이는 항목 수입니다. 항목에서는 양수가 열림을 뜻하며 보이는 자손의 수를 주고, 음수가 닫힘을 뜻하며 그 절댓값은 펼쳤을 때 나타날 자손 수입니다. 예제에서 루트는 3을 담습니다. 챕터 하나에 열린 자식 둘입니다. 모든 /Count를 양수 총합으로 쓰는 생성기는 저자가 접어 둔 분기를 조용히 다시 펼쳐 놓습니다

HotPDF에서 OutlineRoot는 THPDFDocOutlineObject이고, AddChild(Title, X, Y)는 항목을 덧붙여 /Parent, /Prev, /Next, /First, /Last 링크를 연결한 뒤 현재 페이지를 [page /XYZ left top 0]으로 가리킵니다. Opened 속성이 부호를 정하고 작성기는 EndDoc에서 완성된 트리로 모든 /Count를 다시 계산하므로, 자식들을 추가한 뒤에 Opened를 설정해도 됩니다. InsertBefore, InsertAfter, MoveTo가 같은 장부 관리로 트리를 재구성합니다

대상: 클릭이 실제로 착지하는 곳

북마크와 링크 어노테이션 모두 대상(ISO 32000-1 §12.3.2, Table 151)으로 해석되고, 대상은 페이지 번호 이상입니다. 페이지 객체를 짚는 배열에, 뷰어에게 그것을 어떻게 프레임할지 말해 주는 fit 타입이 뒤따릅니다. 일꾼은 [page /XYZ left top zoom]입니다. 세 피연산자 중 무엇이든 null일 수 있는데, "리더가 갖고 있던 것을 유지"라는 뜻이고, 0의 zoom도 null과 같습니다. 그래서 [page /XYZ null null null]은 페이지만 바꾸고 그 외는 건드리지 않는데, "페이지로 이동" 링크가 보통 해야 할 일이 바로 그것입니다

fit 계열은 정확한 위치를 복원력과 바꿉니다. [page /Fit]는 페이지 전체를 창에 맞추고, [page /FitH top]은 주어진 위 모서리로 페이지 폭을 맞추며, [page /FitR left bottom right top]은 사각형을 뷰를 채우도록 확대합니다. 이들은 클릭 시점의 페이지 기하학에서 스케일을 계산합니다. 박제된 zoom을 가진 /XYZ는 페이지 크기가 바뀐 뒤에도 그것을 유지합니다. 목차에는 섹션의 위 모서리에서의 /FitH가 더 나은 기본값입니다

사람들이 다치는 지점은 좌표입니다. 대상 숫자들은 기본 사용자 공간, 즉 왼쪽 아래 원점에 y가 위로 자라는 체계(§8.3.2.3)에 있는 반면, 화면 레이아웃 습관은 위에서 잽니다. HotPDF의 캔버스식 호출(TextOut, 링크 사각형, AddChild의 X와 Y)은 위에서 아래로 가는 좌표를 받아 여러분을 위해 변환합니다. RegisterNamedDestination는 Left와 Top을 대상 배열에 그대로 쓰므로 이미 PDF 사용자 공간이어야 합니다

// BeginDoc과 EndDoc 사이에서, 0..2 페이지가 존재할 때 호출합니다
procedure AddContentsLinks(PDF: THotPDF);
var
  R: TRect;
begin
  // 1페이지 꼭대기에서 50pt 아래의 이름 붙은 대상입니다.
  // Top은 PDF 사용자 공간으로, 아래 모서리에서 위로 잽니다
  PDF.CurrentPageNumber := 1;
  PDF.RegisterNamedDestination('chapter-1', 1, dfmFitH,
    0, PDF.CurrentPage.Height - 50, 0, 0, 0);

  // 0페이지의 목차 항목. 사각형은 위에서 아래로 가는 좌표를 씁니다
  PDF.CurrentPageNumber := 0;
  R := Rect(50, 175, 545, 209);
  PDF.CurrentPage.AddNamedDestinationLink(R, 'chapter-1', 'Chapter 1: Results');

  // YPos = -1은 [page /XYZ null null null]을 씁니다. 페이지를 바꾸고 뷰는 유지합니다
  R := Rect(50, 225, 545, 259);
  PDF.CurrentPage.AddGoToLink(R, 2, -1, 'Methodology');

  R := Rect(50, 275, 545, 309);
  PDF.CurrentPage.AddURILink(R, 'https://www.example.com', 'Project website');
end;

화면식 값 50을 Top으로 넘기면 링크는 아래 모서리에서 50포인트 위, 즉 제목 대신 바닥글을 보여 주는 곳에 착지합니다. 이름 붙은 대상은 카탈로그의 /Dests 이름 트리에 살고 모든 링크가 이름을 통해 해석되므로, 같은 챕터들로 수십 개의 링크가 들어간다면 그것이 맞는 선택입니다. 섹션을 옮기면 /Dests의 항목 하나를 고치고 그것을 부르는 모든 링크가 따라옵니다. 그 대상들이 의존하는 페이지 순서는 페이지 트리 순서가 왜 중요한가에서 다룹니다

어노테이션: 콘텐츠 스트림 밖의 대화형 객체들

어노테이션은 자기 콘텐츠 스트림의 일부가 아닌 채 페이지 위에 앉습니다(ISO 32000-1 §12.5). 링크와 스티키 노트, 하이라이트, 폼 위젯, 파일 첨부 아이콘, 스탬프가 모두 자기 페이지의 /Annots 배열에 삽니다. 항목을 지우면 어노테이션이 사라지고 콘텐츠 스트림은 바이트 단위로 그대로입니다

모든 어노테이션은 작은 코어를 공유합니다(Table 164). /Subtype이 종류를 짚고, /Rect가 기본 사용자 공간의 경계 상자를 주며, /Contents는 보조 기술이 읽어 주는 대체 설명을 겸하는 텍스트를 담습니다. 링크 어노테이션(§12.5.6.5, Table 173)은 두 모양으로 옵니다

20 0 obj                                    % 대상으로 가는 링크
<< /Type /Annot /Subtype /Link
   /Rect [100 200 300 250]
   /Border [0 0 0]
   /Dest [5 0 R /XYZ null null null] >>
endobj
21 0 obj                                    % 액션을 실행하는 링크
<< /Type /Annot /Subtype /Link
   /Rect [50 50 200 100]
   /Border [0 0 0]
   /Contents (Project website)
   /A << /Type /Action /S /URI /URI (https://www.example.com) >> >>
endobj

/Border [0 0 0]에는 이유가 있습니다. 기본 테두리는 [0 0 1], 즉 많은 뷰어가 모든 링크 주위에 그리는 1포인트 프레임입니다. /Dest와 /A는 상호 배타적이고, 액션의 /S가 동작을 고릅니다. 이 파일 안의 /GoTo(§12.6.4.2), 다른 PDF의 페이지를 위한 /GoToR(§12.6.4.3), 웹 주소용 /URI(§12.6.4.7), 외부 애플리케이션이나 파일을 여는 /Launch(§12.6.4.5)가 그 대상입니다. 배포하는 문서에는 /Launch를 넣지 마세요. 뷰어들이 흔히 차단하거나 보안 프롬프트를 앞세우고, PDF/A는 아예 금지합니다(ISO 19005-1 §6.6.1). 위 링크 호출들의 Description 인자가 /Contents를 채우고, HotPDF의 PDFUACompliance를 켜면 빈 설명은 예외를 던집니다. PDF/UA-1이 모든 링크에 그 항목을 요구하기 때문입니다

하이라이트와 노트 같은 마크업 어노테이션과 /Square 같은 모양에는 비틀림이 하나 있습니다. 타입이 모양을 고정하지 않습니다. 외형 스트림(/AP 항목, §12.5.5)이 없으면 각 뷰어가 자기 렌디션을 그립니다. ISO 32000-2는 대부분의 어노테이션 타입에 /AP를 의무화하되 Popup과 Link를 예외로 둡니다. 파일 첨부는 같은 기계를 재사용합니다. 파일 명세(§7.11)로 감싸인 포함 파일 스트림이, 페이지의 /FileAttachment 어노테이션이나 카탈로그 /Names 아래의 /EmbeddedFiles 이름 트리로 표면화됩니다

파일을 출하하기 전에 이 계층을 어떻게 검사하나?

검사 두 가지가 대부분을 잡습니다. 실제 뷰어에서 북마크 패널과 링크 표본을 클릭해 보고, 프로그램으로 메타데이터를 다시 읽는 것입니다. 클릭으로는 XMP와 어긋나는 /Info가 절대 드러나지 않기 때문입니다. HotPDF의 적재 문서 호출이 양쪽 절반을 커버합니다. GetLoadedOutlineTitlesDeep는 뷰어가 하는 것과 같은 순회를, 즉 /First와 /Next 사슬을 깊이 우선으로 걷므로 낡은 링크에 잘린 분기는 그 결과에서도 빠져 있습니다. FindLoadedOutlineByTitle은 북마크의 대상 페이지를 해석하고 대상이 이름 붙은 대상이거나 페이지로 해석되지 않으면 -1을 보고합니다

// StripXmlTags는 직접 추가하는 헬퍼입니다. 라이브러리 호출이 아닙니다.
// GetLoadedXMPValue는 요소의 내부 마크업을 돌려주므로 dc:creator는
// rdf:Seq와 rdf:li 태그에 감싸여 돌아옵니다
function StripXmlTags(const S: AnsiString): AnsiString;
var
  I: Integer;
  InTag: Boolean;
begin
  Result := '';
  InTag := False;
  for I := 1 to Length(S) do
    if S[I] = '<' then InTag := True
    else if S[I] = '>' then InTag := False
    else if not InTag then Result := Result + S[I];
  Result := AnsiString(Trim(string(Result)));
end;

procedure CheckNavigationLayer(const FileName: string);
var
  PDF: THotPDF;
  Title, Author, Subject, Keywords, Creator, Producer, XmpAuthor: AnsiString;
  Titles: THPDFAnsiStringArray;
  I, PageIndex: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.AutoLaunch := False;
    if PDF.LoadFromFile(FileName) <= 0 then
      raise Exception.Create('No readable pages');

    if PDF.GetLoadedDocumentInfo(Title, Author, Subject, Keywords,
      Creator, Producer) then
    begin
      XmpAuthor := StripXmlTags(PDF.GetLoadedXMPValue('dc', 'creator'));
      if (XmpAuthor <> '') and (XmpAuthor <> Author) then
        Writeln('Author mismatch: /Info="', Author, '" XMP="', XmpAuthor, '"');
    end;

    Titles := PDF.GetLoadedOutlineTitlesDeep;
    Writeln(Length(Titles), ' bookmarks reachable');
    for I := 0 to High(Titles) do
      if PDF.FindLoadedOutlineByTitle(Titles[I], PageIndex, False, True)
        and (PageIndex < 0) then
        Writeln('No direct page target: ', Titles[I]);
  finally
    PDF.Free;
  end;
end;

쓴 것보다 적은 북마크 수는 깨진 형제 사슬을 가리킵니다. 이름 붙은 대상을 쓰는 북마크의 -1은 예상된 것이고, FindLoadedOutlineByTitle은 첫 일치에서 멈추므로 중복된 제목은 첫 것의 대상을 보고합니다. GetLoadedXMPValue는 패킷 위의 평범한 텍스트 검색이고 비교는 바이트 단위이므로, 비 ASCII 이름이라면 불일치를 믿기 전에 두 값이 어떻게 돌아오는지 보세요

이 계층의 모든 것은 참조 장부 관리이고, 장부 관리야말로 라이브러리에 맡길 가치가 있는 부분입니다. Delphi와 C++Builder용 HotPDF Delphi Component는 한 세트의 속성에서 두 메타데이터 저장소를 쓰고, 항목을 추가하는 대로 개요 링크를 꿰며, 링크 어노테이션을 액션과 설명과 함께 붙입니다. 적재 문서 API는 검사를 위해 같은 구조들을 다시 읽습니다