기술 문서

다시 쓰지 않고 Delphi에서 로드된 PDF 메타데이터 편집하기

여러 생성기에서 나온 계약서 PDF가 만 건이나 있고, 법무팀은 그 모두가 올바른 Author, 수정된 Producer 문자열, 그리고 시작할 때 북마크 패널을 여는 읽기 모드까지 갖추길 원한다. 가장 단순한 해결책은 각 파일을 불러와 페이지를 다시 배치하고 새 문서를 쓰는 것이다. 그렇게 하면 기존 object number, incremental-update 기록, 모든 디지털 서명, 그리고 원래 도구가 내보낸 정교하게 조정된 xref를 모두 버리게 된다. 페이지는 똑같아 보이지만 파일은 구조적으로는 완전히 다른 문서가 된다. 메타데이터만 고치려는 작업에서는 전혀 맞지 않는 선택이다

올바른 방법은 로드된 문서를 제자리에서 수정하는 object graph로 보는 것이다. Info dictionary, /Metadata stream, 그리고 Catalog에 들어가 관심 있는 몇 개의 항목만 바꾸고 결과를 다시 저장하면 된다. Delphi와 C++Builder용 네이티브 VCL PDF 컴포넌트인 HotPDF는 로드된 문서 쓰기 API를 통해 바로 그 기능을 제공한다. 이 글은 그 API를 올바르게 사용하는 방법과, 거의 모든 사람이 저지르는 한 가지 실수, 즉 Info dictionary만 편집하고 같은 메타데이터의 두 번째 사본이 XMP에 있다는 사실을 잊는 문제를 다룬다

같은 메타데이터가 두 곳에 저장되며, 서로 어긋날 수 있다

PDF는 문서 정보를 두 개의 병렬 위치에 담고 있으며, 이것이 대부분의 "제목을 바꿨는데도 Acrobat에는 예전 값이 보인다"는 문의의 근본 원인이다. 첫 번째는 문서 정보 dictionary, 즉 전통적인 /Info object로, /Title, /Author, /Subject, /Keywords, /Creator, 그리고 /Producer 키를 가진다. 정의는 ISO 32000-1 §14.3.3에 있다. 두 번째는 XMP packet으로, Catalog 아래의 /Metadata에 매달린 stream으로 저장되는 XML 문서이며, §14.3.2에 정의되어 있고 Adobe XMP data model을 기반으로 한다

둘 다 title을 담을 수 있다. 규격 어디에도 두 값이 반드시 같아야 한다고는 적혀 있지 않다. 최신 뷰어와 대부분의 PDF/A validator는 XMP packet이 있으면 그것을 우선 사용하고, 없을 때만 Info dictionary로 돌아간다. 따라서 /Info - 이것이 대부분의 "set PDF metadata" 코드가 하는 일이다 - 만 수정하면 XMP를 신뢰하는 뷰어는 오래된 값을 계속 보여 주고, PDF/A 검사기는 불일치를 표시한다. 이미 XMP packet이 있는 파일에서는 Info entry XMP도 다시 생성해 둘이 일치하도록 해야 한다. HotPDF는 이 두 가지를 모두 제공하지만, 함께 쓰는 일은 사용자의 몫이다

Info dictionary 편집하기

Info 쪽 helper들은 얇고 예측 가능하다. SetLoadedTitle, SetLoadedAuthor, SetLoadedSubject, SetLoadedKeywords, SetLoadedCreator, 그리고 SetLoadedProducer는 각각 하나의 AnsiString를 받아 로드된 Info dictionary에 해당 키를 기록하고, 키가 이미 있으면 값을 바꾸고 없으면 새로 추가한다. 키를 완전히 제거하려면, 예를 들어 내부 도구 이름이 드러나는 /Creator 같은 항목은 RemoveLoadedInfoKey를 키 이름만 넣어 호출하면 된다. 이들 함수는 XMP에는 전혀 손대지 않고, 순수하게 /Info object만 다룬다. 이 object는 LoadFromFile이 파일을 파싱할 때 찾아낸 것이다

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-in.pdf', '') > 0 then
    begin
      Pdf.SetLoadedTitle('Master Services Agreement 2026');
      Pdf.SetLoadedAuthor('Legal Department');
      Pdf.SetLoadedSubject('Executed contract, retention 7 years');
      Pdf.SetLoadedKeywords('contract; MSA; 2026; executed');
      Pdf.SetLoadedProducer('Acme Document Pipeline');
      Pdf.RemoveLoadedInfoKey('Creator');  // drop the originating tool name
      Pdf.SaveLoadedDocument('contract-out.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

한 가지는 정확히 짚고 넘어가야 한다. 이 함수들은 AnsiString. ASCII 제목이라면 문제 없지만, 비라틴 문자가 필요한 PDF text string은 넘기기 전에 규격이 요구하는 방식으로 인코딩해야 한다. UTF-16BE에 byte-order mark를 붙이거나 PDFDocEncoding을 써야 한다. 라이브러리는 넘겨 받은 바이트를 그대로 string object에 쓸 뿐, 인코딩을 추측해 주지 않는다. 제목이 단순한 영어라면 신경 쓰지 않아도 된다. 악센트가 있거나 CJK 문자가 들어간다면 의도적으로 인코딩하고 실제 viewer에서 확인해야 한다

XMP packet 다시 쓰기

SetLoadedXMPMetadata 는 이 이중 쓰기의 다른 절반이다. 전체 XMP packet을 AnsiString로 넘기면 두 가지 중 하나를 한다. Catalog가 이미 /Metadata stream을 참조하고 있으면 그 stream의 내용을 같은 object number를 유지한 채 제자리에서 교체하고, metadata stream이 없으면 새로 만들고 /Type /Metadata로 표시한 뒤 /Subtype /XML object number를 할당하고 Catalog에 연결한다. 어느 경우든 뷰어가 읽을 수 있는 유효한 metadata object를 얻게 된다

사용자가 XML을 직접 공급하므로 schema를 통제하게 된다. dc:title, dc:creator, xmp:CreatorTool, 그리고 그 밖의 항목들이다. 이것은 권한이자 책임이기도 하다. 라이브러리는 사용자가 넣은 packet을 파싱하거나 검증하지 않으며, 바이트를 압축하지 않은 채 stream filter도 적용하지 않고 그대로 쓴다. 잘못된 packet도 호출은 그냥 통과하고, 나중에 broken-metadata 문의로 돌아올 뿐이다. XML은 신중하게 만들고, Info dictionary에 쓴 값과 정확히 일치시키면 두 뷰가 서로 모순되지 않는다

const
  XMP_TEMPLATE =
    '<?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/">' +
    '<dc:title><rdf:Alt><rdf:li xml:lang="x-default">%s</rdf:li></rdf:Alt></dc:title>' +
    '<dc:creator><rdf:Seq><rdf:li>%s</rdf:li></rdf:Seq></dc:creator>' +
    '</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
begin
  // After setting the Info dictionary, mirror the same values into XMP:
  Pdf.SetLoadedTitle('Master Services Agreement 2026');
  Pdf.SetLoadedAuthor('Legal Department');
  Pdf.SetLoadedXMPMetadata(
    AnsiString(Format(XMP_TEMPLATE,
      ['Master Services Agreement 2026', 'Legal Department'])));
  Pdf.SaveLoadedDocument('contract-out.pdf');
end;

그 순서, 즉 Info 먼저, XMP 둘째, 그다음 save가 익혀야 할 패턴이다. 두 호출은 서로 독립적이며, 일관성은 단지 같은 문자열을 넘겼기 때문에 생긴다. XMP packet이 있는 파일에서 XMP 호출을 건너뛰면, 이 섹션 전체가 막으려는 조용한 오래됨 버그로 되돌아간다

Diagram showing a PDF Info dictionary and an XMP metadata stream both holding title and author, edited in place alongside the bookmark outline tree
메타데이터는 두 곳, 즉 Info dictionary와 XMP stream에 있고, Catalog 수준의 읽기 힌트와 outline tree도 있다. 제자리 편집은 문서를 다시 만들지 않고 이들 모두를 건드린다.

뷰어가 파일을 여는 방식을 제어하기

문서가 열리는 즉시 독자가 보는 내용은 Catalog의 세 항목이 결정하며, 이 셋은 모두 로드된 graph에서 한 줄 수정으로 끝난다. SetLoadedPageMode/PageMode를 name object로 쓴다. 'UseOutlines'를 넘기면 bookmark panel이 뜨고, 'UseThumbs'를 넘기면 thumbnail rail이 나타나며, 'FullScreen'를 넘기면 presentation mode가 되고, 'UseAttachments'를 넘기면 attachments pane이 열린다. (ISO 32000-1 §7.7.3.1, Table 28) SetLoadedPageLayout/PageLayout도 같은 방식으로 쓴다. 'SinglePage', 'OneColumn', 'TwoColumnLeft', 그리고 나머지도 마찬가지다. 둘 다 앞의 slash 없이 이름만 받으며, 라이브러리가 출력 시 slash를 붙인다

SetLoadedLanguage는 Catalog의 /Lang entry, 즉 문서 전체를 위한 자연어 태그를 쓴다. 'en-US', 'de-DE' 즉 BCP 47 tag다. 사람들이 자주 헷갈리는 타입 차이에 주의하자. /PageMode/PageLayout은 PDF name object이고, /Langstring. HotPDF는 내부적으로 이 부분을 올바르게 처리하지만, 출력물을 직접 보면 /PageMode /UseOutlines/Lang (en-US)와 대응하는 것을 볼 수 있고, 이제 그 이유도 알 수 있다. /Lang entry는 보이는 것보다 중요하다. 보조 기술이 발음을 결정할 때 읽는 값이며, PDF/UA 접근성 적합성의 필수 조건이다

if Pdf.LoadFromFile('handbook.pdf', '') > 0 then
begin
  Pdf.SetLoadedPageMode('UseOutlines');     // /PageMode, a name
  Pdf.SetLoadedPageLayout('TwoColumnLeft'); // /PageLayout, a name
  Pdf.SetLoadedLanguage('en-US');           // /Lang, a string
  Pdf.SaveLoadedDocument('handbook-tagged.pdf');
end;

tree를 건드리지 않고 bookmark 이름 바꾸기

bookmark 제목 수정은 일상적인 정리 작업이다. 제목 오타를 바로잡거나 outline을 만든 뒤 장 번호가 바뀌는 경우처럼 말이다. SetLoadedOutlineTitletop-level outline 항목의 zero-based index와 새 title을 받아 Catalog → /Outlines/First/Next 체인을 따라 해당 위치로 이동한 뒤 항목의 /Title string을 바꾼다. 바뀌는 것은 제목뿐이며, destination, open/closed 상태, child 구조는 건드리지 않는다

if Pdf.LoadFromFile('report.pdf', '') > 0 then
begin
  Pdf.SetLoadedOutlineTitle(0, 'Executive Summary');
  Pdf.SetLoadedOutlineTitle(1, 'Financial Results');
  Pdf.SaveLoadedDocument('report-renamed.pdf');
end;

이름 변경이 안전한 이유는 구조적 카운터를 전혀 건드리지 않기 때문이다. outline 항목을 삭제할 때 문제가 생기는데, 단순히 이름만 바꾸는 상황에서도 이 점을 이해할 가치가 있다. 하지 말아야 할 항목을 알려 주기 때문이다. 각 outline node는 /Count를 가지며, ISO 32000-1 §12.3.3에 따르면 그 count는 바로 아래 자식의 수가 아니다. 그것은 다음의 총합이다. 보이는 후손 노드: 양수 /Count of N은 현재 N개의 후손 노드가 펼쳐져 있음을 뜻하고, 음수 값은 node에 후손 노드가 있지만 접혀 있음을 뜻한다. top-level 항목을 제거할 때는 /Outlines root count를 단순히 1 줄일 수 없다. 남아 있는 각 top-level node에 대해 "node 자신 1개에 그 node의 양수 /Count를 더한 값"을 합산해 다시 계산해야 한다. 접힌(음수 count) node의 descendants는 건너뛰어야 한다. 이걸 잘못하면 reader가 표시하는 bookmark 총수가 어긋나고, 삭제 한 번마다 한 개보다 더 많이 점프한다. 이름 바꾸기는 이 모든 문제를 피하므로, dictionary를 직접 건드리기보다 전용 helper를 쓰는 또 하나의 이유가 된다

저장이 제자리에 머무는 방식

위의 모든 편집은 메모리의 object만 바꾼다. 디스크에는 SaveLoadedDocument가 실행되기 전까지는 아무 것도 기록되지 않는다. 이 방식이 저렴한 이유는 save가 문서를 다시 생성하지 않기 때문이다. load 시 HotPDF가 파싱한 기존 object number와 구조를 유지한 채, 변경되거나 새로 할당된 몇 개의 object만 얹은 동일한 graph를 다시 쓴다. 덕분에 metadata 작업이 파일 전체를 다시 쓰지 않으며, object streams와 incremental updates가 동작하게 하는 것과 같은 메커니즘이다. 원본 파일이 Word나 다른 office suite에서 나온 것이라면, 편집 전에 알아둘 만한 object 배치의 특성이 따로 있다. Office PDF의 hybrid-reference cross-reference streams 글이 그런 파일의 구조와 왕복 후에도 남는 것들을 설명한다

지켜야 할 경계가 두 가지 있다. 첫째, 이것은 제자리 편집 모델이지 redaction이나 sanitization 도구가 아니다. Info key를 지우면 그 key는 없어지지만, 같은 파일의 이전 incremental-update generation에 남아 있을 수 있는 오래된 값까지 닦아 내지는 못한다. 민감한 metadata를 실제로 제거해야 한다면 그것은 다른, 훨씬 무거운 작업이다. 둘째, XMP 쓰기는 literal하다. 라이브러리는 XML을 신뢰하고 검증하지 않으므로, PDF/A나 엄격한 validator로 갈 파일이라면 검증된 template에서 packet을 생성하고 출력을 확인해야 한다. 이 선 안에서 쓰면 제자리 metadata 편집은 딱 알맞은 도구다. 잘못된 몇 바이트만 고치고, 이미 맞았던 파일의 나머지 99%는 원래 생산자가 쓴 그대로 둔다

여기서 보여 준 loaded-document write API는 표준 HotPDF Component for Delphi와 C++Builder용으로 제공되며, metadata, outline, Catalog 편집 메서드 전체와 함께 포함된다