기술 문서

PDF 생명주기 액션: Delphi에서 카탈로그 /AA 대 페이지 /AA

네이티브 Delphi/C++Builder PDF 라이브러리인 PDFlibPas는 PDF 문서에 자동 동작을 걸 수 있는 두 개의 별개 장소를 제공한다: 카탈로그의 /AA 딕셔너리에 저장되는 WillClose, WillSave, DidSave, WillPrint, DidPrint 같은 문서 수준 생명주기 액션과, 대신 각 Page 객체 자신의 /AA 딕셔너리에 저장되는 Open과 Close라는 페이지 수준 생명주기 액션이다. 이 두 컨테이너를 혼동하는 것이 생명주기 액션이 조용히 아무 일도 하지 않게 되는 단 하나의 가장 흔한 원인이다

이를 촉발하는 사례는 평범하다. 재무팀은 파일이 그저 열릴 때가 아니라 실제로 인쇄가 시작되는 순간 인쇄 타임스탬프를 찍고 누가 인쇄했는지 로그로 남기는 명세서 템플릿을 원한다. 폼이 많은 워크플로는 리더의 PDF 클라이언트가 창을 닫도록 허용되기 전에 필드 값이 자동으로 서버에 밀어 넣어져야 해서, 닫힌 탭이 절대 잃어버린 편집을 의미하지 않게 한다. 다중 페이지 보고서는 그 페이지가 화면에 있는 동안에만 나타나는 페이지 특정 배너를 원한다. PDF는 실제로 이런 종류의 동작을 위해 문서와 페이지 아래에 세 번째 계층을 제공한다 — 개별 폼 필드나 링크 자신의 /A 항목에 붙는 액션으로, 인터랙티브 폼 액션과 JavaScript에 관한 자매 글의 주제다 — 하지만 이 글은 그 위의 두 계층, 즉 문서 전체와 단일 페이지에 머문다

문서 카탈로그의 /AA에는 어떤 트리거가 있는가?

다섯 개의 트리거가 카탈로그 /AA 딕셔너리에 있으며, 각각은 단일 페이지가 아니라 문서 전체에 영향을 주는 이벤트에 대해 발생한다. ISO 32000-1 §12.6.3(Trigger Events)은 문서 수준 키를 각각 WillClose, WillSave, DidSave, WillPrint, DidPrint에 대응하는 WC, WS, DS, WP, DP/AA 딕셔너리에 그대로 기록되는 문자 그대로의 두 글자 이름 — 로 나열하며, PDFlibPas는 그 집합을 TPDFlibDocumentActionTrigger 열거형에 정확히 반영한다: datWillClose, datWillSave, datDidSave, datWillPrint, datDidPrint다. SetDocumentAction은 다섯 중 어느 것이든 연결하는 단일 진입점이며, 이 함수가 받는 ActionKind 매개변수는 순수한 URI부터 스크립트, 대상 이동까지 라이브러리 전체 액션 빌더 호출에서 공유되는 10개의 PDF_ACTION_BUILDER_* 상수 중 하나다. GoTo, 원격 파일, 임베디드 파일, 또는 Launch 액션이 촉발된 뒤 실제로 무엇을 하는지는 그것이 어디에 연결되는지와는 다른 질문이며, 이는 GoTo, 원격, 임베디드, launch 액션에 관한 자매 글의 주제다 — 이 글은 액션 종류 질문이 아니라 컨테이너 질문, 즉 카탈로그냐 페이지냐에 머문다

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.AddStandardFont(4);
    Lib.DrawText(40, 700, 'Quarterly statement');
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save', '', 0, 0);
    Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_SUBMIT,
      'https://example.com/forms/submit', 'CustomerName;OrderTotal', 0, 0);
    Lib.SaveToFile('statement.pdf');
  finally
    Lib.Free;
  end;
end;

페이지 수준 트리거는 문서 수준 트리거와 어떻게 다른가?

페이지 수준 트리거는 그것이 연결된 단일 Page 객체에 대해서만 발생하며, PDFlibPas는 이를 카탈로그가 아니라 그 페이지 자신의 /AA 딕셔너리에 저장한다. ISO 32000-1이 페이지의 추가 액션 딕셔너리를 위해 정의하는 OC 키에 대응하는 Open과 Close, 이렇게 페이지 트리거는 두 개뿐이며, PDFlibPas는 이를 SetPageAction을 통해 patOpenpatClose로 노출하는데, 이 함수는 SelectPage로 현재 선택된 페이지에 연결된다 — 이는 문서 전체를 루프 돌면서 호출 하나가 모든 곳에 적용될 것으로 기대하는 첫 순간 중요해지는 세부사항인데, 실제로는 절대 그렇지 않기 때문이다. 어느 종류든 트리거를 연결하는 것은 파일의 최소 PDF 버전도 끌어올리며, 두 컨테이너는 서로 다른 하한을 요구한다: PDFlibPas는 카탈로그 /AA 항목을 처음 쓸 때 문서를 최소 PDF 1.4로, 페이지 /AA 항목을 처음 쓸 때는 최소 PDF 1.5로 끌어올리며, 그 안에 어떤 종류의 액션이 있든 상관없다. 이는 액션 자체가 스스로 필요로 하는 것 위에 얹히는 컨테이너 수준 요구사항이다. 그래서 단독으로는 PDF 1.1만 요구할 순수한 URI 액션도 페이지 열기 트리거로 감싸이는 순간 파일 전체를 PDF 1.5로 끌어올린다

Lib.SelectPage(3);
Lib.SetPageAction(patOpen, PDF_ACTION_BUILDER_JAVASCRIPT,
  'app.alert("Section 3: internal review only");', '', 0, 0);
Lib.SetPageAction(patClose, PDF_ACTION_BUILDER_WEB,
  'https://example.com/analytics/page-3-closed', '', 0, 0);

생명주기 액션 읽기와 제거하기

GetDocumentActionInfoGetPageActionInfo 둘 다 TPDFlibActionInfo 레코드를 반환하며, Kind 필드는 해당 트리거에 아무것도 연결되어 있지 않을 때마다 akNone으로 돌아온다. 그래서 레코드의 다른 어떤 필드든 신뢰하기 전에 Kind를 확인하라 — URI, JavaScript, FileName 등은 오직 Kind가 실제로 보고하는 그 하나의 액션 종류에 대해서만 의미가 있는데, 같은 레코드 형태가 빌더가 만들 수 있는 모든 액션 유형에 걸쳐 재사용되기 때문이다. RemoveDocumentActionRemovePageAction은 각각 단일 트리거를 지우며 제거할 것을 찾았으면 1을, 트리거가 이미 비어 있었으면 0을 보고한다; 제거된 항목이 /AA 딕셔너리에 남아 있던 마지막 것이었을 때, PDFlibPas는 카탈로그나 페이지에 매달린 무의미한 컨테이너를 남겨두는 대신 이제 비어버린 /AA 자체를 삭제한다

var
  Info: TPDFlibActionInfo;
begin
  Info := Lib.GetDocumentActionInfo(datWillSave);
  if Info.Kind = akURI then
    WriteLn('WillSave calls out to: ', string(Info.URI));

  if Lib.RemoveDocumentAction(datWillSave) = 1 then
    Lib.SetDocumentAction(datWillSave, PDF_ACTION_BUILDER_WEB,
      'https://example.com/audit/will-save-v2', '', 0, 0);
end;

PDF/A는 생명주기 액션을 아예 허용하는가?

아니다. PDF/A 적합성은 위험해 보이는 액션 종류만이 아니라 추가 액션 컨테이너 전체를 거부하는데, ISO 19005가 아카이브 파일은 몇십 년 뒤에도 그때는 존재하지 않을지 모르는 스크립트 엔진이나 네트워크 연결에 의존하지 않고 같은 방식으로 렌더링되어야 한다는 가정 위에 PDF의 인터랙티브 액션 모델을 제한하기 때문이다. SetDocumentActionSetPageAction 둘 다의 뒤에 있는 공유 빌더인 SetLifecycleActionActionKind를 보기도 전에 PDFAMode를 확인한다. 그래서 그저 회사 웹페이지를 여는 URI 액션이나 다음 페이지로 가는 것만 의미하는 Named 액션도 위험한 액션과 같은 그물에 걸린다 — 보안 검토자가 일반적으로 표시할 만한 것이 전혀 아니어도 어쨌든 차단되는데, 이 제한이 사례별이 아니라 구조적이기 때문이다. 실무적인 위험은 이 거부가 조용하다는 것이다: SetDocumentActionSetPageAction 둘 다 예외를 일으키지 않고 0을 반환하므로, 반환값을 절대 확인하지 않는 호출 지점은 원래 가져야 했던 트리거가 조용히 빠진 문서를 배포한다

Lib.SetPDFAMode(2); // PDF/A-1b
if Lib.SetDocumentAction(datWillClose, PDF_ACTION_BUILDER_NAMED,
     '', '', 0, 0) = 0 then
  // rejected: PDF/A-1b forbids Catalog /AA, even a plain Named action
  WriteLn('lifecycle action not attached');

기억해 둘 가치가 있는 비대칭이 하나 있다. RemoveDocumentActionRemovePageAction은 절대 PDFAMode를 확인하지 않으므로, 이미 규격에 맞지 않는 생명주기 액션을 담은 파일을 로드해 PDF/A 준수 저장으로 가는 길에 그것들을 벗겨내는 것은 기대한 대로 정확히 작동한다 — 오직 새 트리거를 연결하는 쓰기 경로만이 컴플라이언스 모드에 의해 관문이 세워진다

WillOpen 트리거 없이 열기 시 인쇄는 어디에 들어맞는가?

카탈로그 /AA 딕셔너리에는 설계상 WillOpen 항목이 전혀 없다 — ISO 32000-1의 문서 수준 /AA는 정확히 다섯 개의 키인 WillClose, WillSave, DidSave, WillPrint, DidPrint를 정의하며, 그 목록의 어느 것도 순수하게 파일이 열렸다는 이유만으로 발생하지 않는다. 열기 시점 훅은 별도의 카탈로그 항목인 /OpenAction에 있으며, PDFlibPas는 이를 자신만의 호출 계열로 노출한다. SetOpenActionJavaScript, SetOpenActionDestination, SetOpenActionNamedDestination 등이며, 이 중 어느 것도 /AA 딕셔너리나 TPDFlibDocumentActionTrigger 열거형을 전혀 건드리지 않는다. 하지만 두 메커니즘은 조합될 수 있고, 이것이 보통 열기 시 인쇄 템플릿이 실제로 필요로 하는 것이다: 템플릿의 /OpenAction이 인쇄 작업을 시작하도록 만들라 — 보통 뷰어 자신의 인쇄 명령을 호출하는 JavaScript 액션이다 — 그리고 그 인쇄 자체가 WillPrint와 DidPrint에게 실행할 대상을 준다: 페이지가 스풀되기 전에 찍히는 타임스탬프, 다 끝난 뒤에 작성되는 감사 항목이다

이 트리거들은 PDF 뷰어 전반에 걸쳐 얼마나 신뢰할 만한가?

PDF/A 밖에서도 모든 뷰어가 이를 실행하는 것은 아니므로, 생명주기 액션을 보장이 아니라 요청으로 취급하라. Acrobat과 대부분의 완전한 데스크톱 리더는 전체 집합을 충실히 실행하지만, 실제 PDF 소비의 상당 부분은 추가 액션 딕셔너리를 전혀 건드리지 않는다: 브라우저 내장 뷰어, 대부분의 모바일 리더, 그리고 거의 모든 서버 사이드 렌더링이나 텍스트 추출 파이프라인은 /AA를 완전히 무시하거나 그 좁은 일부만 존중하는데, 헤드리스 변환에는 훅으로 걸 인쇄 작업이 없으므로 WillPrint와 DidPrint가 보통 가장 나쁘게 처리된다. WillClose 폼 제출 액션이 폼 데이터를 포착하는 유일한 경로라면, 그것은 신뢰할 만한 경로가 아니다 — 명시적인 제출 버튼과 짝을 지어라. 그리고 이 자동 트리거는 그것을 우연히 지원하는 리더를 위한 편의로만 취급하라

문서, 페이지, 필드 트리거는 같은 밑에 있는 액션 딕셔너리 메커니즘의 세 계층이며, 컨테이너가 명확해지면 나머지는 올바른 ActionKind 상수를 고르고 반환 코드를 확인하는 문제다. 이 생명주기 트리거들은 이 글이 다루는 더 넓은 액션 빌더 API와 함께 표준 PDFlibPas Delphi PDF 라이브러리의 일부로 제공되며, 제품 문서에는 전체 트리거 및 액션 종류 레퍼런스가 실려 있다