기술 문서

Delphi PDF의 GoToR, GoToE, Launch 액션

PDFlibPas는 Delphi와 C++Builder 개발자에게 현재 페이지를 벗어나는 탐색을 위한 세 가지 액션 종류를 제공한다: GoToR(Go To Remote)는 다른 PDF 파일의 특정 페이지를 열고, GoToE(Go To Embedded)는 현재 문서 안에 임베드된 PDF 파일을 열며, Launch는 운영체제 셸을 통해 외부 프로그램을 실행하거나 파일을 연다. 셋 다 일상적인 GoTo 액션도 정의하는 ISO 32000-1 §12.6.4, Action Types 절에 자리하며, 각각 부주의한 사람을 위한 자신만의 함정을 담고 있다: 어느 호출이 만드느냐에 따라 다른 의미를 갖는 페이지 번호, 파일 경로가 아니라 이름인 대상, 그리고 똑같아 보이지만 두 개의 서로 다른 뷰어를 위한 문자열 매개변수 쌍이다

이 중 어느 것도 가상의 이야기가 아니다. 기술 참고 자료 패키지 — 메인 매뉴얼, 유통사가 자체 일정으로 갱신하는 사양서 PDF, 이 둘과 함께 설치되는 교정 유틸리티 — 는 정확히 이런 종류의 문서 간 연결에 의존한다: 사양서 파일의 5페이지에 도달해야 하는 상호 참조, 옆이 아니라 매뉴얼 안에 실어 보낼 가치가 있는 데이터시트, 교정 도구로 곧바로 넘겨주는 링크다. 이 글은 기존 PDF에서 북마크와 주석 액션을 다시 읽어내기의 거울상이다: 그 글은 다른 생성기가 이미 파일에 써넣은 GoToR, Launch, GoToE 액션을 소비하는 것을 다루며, 이 글은 PDFlibPas가 단 한 바이트도 커밋하기 전에 강제하는 필드 수준 규칙까지 포함해 처음부터 그 같은 세 가지 액션 종류를 만드는 것을 다룬다

PDF 액션이 현재 페이지를 벗어나는 세 가지 방법

PDFlibPas는 액션의 /S 키에서 로컬 탐색을 나머지 모든 것과 분리하며, GoToR, GoToE, Launch는 대상이 현재 페이지 밖에 있는 세 가지 서브타입이다: ISO 32000-1 §12.6.4.3의 GoToR, §12.6.4.4의 GoToE, §12.6.4.5의 Launch로, 모두 일상적인 GoTo 액션도 정의하는 더 넓은 §12.6.4 Action Types 절 안에 있다. 평범한 GoTo 액션의 대상은 문서 안에 이미 존재하는 페이지 객체를 이름 붙이므로 PDFlibPas는 즉시 이를 검증할 수 있다; GoToR와 GoToE는 같은 방식으로 그렇게 할 수 없는데, 외부 파일이 이 컴퓨터에 아예 존재하지 않을 수도 있고 임베디드 파일의 페이지 수는 호스트 문서가 추적하는 무언가가 아니기 때문이다. 그래서 둘 다 강한 링크 대신 해석되지 않은 참조를 담는다 — GoToR의 경우 파일 지정과 대상, GoToE의 경우 임베디드 파일 이름과 대상 페이지다 — 반면 Launch는 대상 개념을 완전히 버리고 운영체제가 실행하거나 열 무언가의 이름만 붙인다. 이 분리는 쓰기 쪽에서 두 개의 호출 계열로 나타난다: AddLinkToFile, AddLinkToFileEx, AddLinkToEmbeddedPDF, AddLinkToLocalFile 같은 고수준의 원콜 빌더는 페이지 핫스팟 링크 주석과 그 액션을 함께 만들어, 대부분의 실제 레이아웃 — 독자가 클릭하는 텍스트 한 줄이나 아이콘 — 을 커버한다. 반면 SetActionRemoteDestinationEx, SetActionLaunchOptions 같은 저수준 세터와 그 AddActionNext* 대응물은 이미 핸들을 가지고 있는 무언가 — 기존 북마크, 폼 필드 트리거, 문서나 페이지 수준 생명주기 이벤트 — 에 액션을 연결하거나 대체한다. 두 계열 모두 결국 같은 딕셔너리 형태를 쓴다; 차이는 여러분이 그것을 호출할 때 어디 서 있는지, 그리고 다음 절에서 다루듯 그때 페이지 번호가 무엇을 의미하는지다

다른 PDF 파일의 페이지를 여는 GoToR 링크는 어떻게 만드는가?

GoToR 액션에는 두 가지가 필요하다 — 파일 지정과 그 파일 안의 대상 — 그리고 PDFlibPas는 두 번째 부분을 공급하기 위한 두 가지 다른 호출을 제공하며, 각각 자신만의 페이지 번호 규칙을 가진다. 고수준 페이지 핫스팟 빌더인 AddLinkToFileAddLinkToFileEx는 그 PageDestPage 인자를 0보다 크다는 조건으로 검증하는데, 이는 SelectPage를 포함해 PDFlibPas가 다른 모든 곳에서 사용하는 것과 같은 1부터 시작하는 번호 매김이다. 이미 핸들을 가지고 있는 무언가에 GoToR 액션을 연결하거나 대체하는 데 쓰이는 저수준 세터인 SetActionRemoteDestinationEx는 대신 DestPage를 0 이상이라는 조건으로 검증하고 아무 조정 없이 그대로 액션의 명시적 대상 배열에 써넣는다: 이는 대상 문서의 원시적인 0부터 시작하는 페이지 인덱스를 원하며, 이는 ISO 32000-1이 원격 명시적 대상을 위해 명시하는 번호 매김이다. 고수준 빌더에 넘길 것과 같은 숫자로 저수준 세터를 호출하면 링크는 한 페이지 일찍 열린다

var
  Lib: TPDFlib;
  ActionID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('manual.pdf', '') = 1 then
    begin
      Lib.SelectPage(12);
      // Page is 1-based here, same as SelectPage above: this opens
      // the fifth page of specs.pdf.
      Lib.AddLinkToFile(72, 700, 200, 16, 'specs.pdf', 5, 0, 0, 0);

      // A later maintenance pass repoints the same link at a
      // reorganized file. SetActionRemoteDestinationEx edits the
      // action directly, and DestPage here is the zero-based index
      // PDF itself uses for a remote explicit destination -- "the
      // fifth page" is now 4, not 5.
      ActionID := Lib.GetAnnotActionID(1);
      Lib.SetActionRemoteDestinationEx(ActionID, 'specs-2026.pdf',
        4, Ord(dkFit), 0, 0, 0, 0, 0, 0, -1);
    end;
  finally
    Lib.Free;
  end;
end;

SetActionRemoteDestinationEx의 나머지 인자들도 마찬가지로 문자 그대로다. ValueMask는 비트 집합이다 — left는 1, top은 2, right는 4, bottom은 8, zoom은 16 — 그리고 PDFlibPas는 무엇이든 쓰기 전에 이를 DestType과 대조해 확인한다: dkFitR 대상은 정확히 15(zoom 없이 네 모서리 모두)를 공급해야 하고, dkFitdkFitB0을 공급해야 하며, dkFitH/dkFitV는 자신과 관련 있는 좌표 하나만 받아들인다. 그렇지 않으면 유효한 마스크 안에서 설정하지 않고 남겨둔 비트는 배열에서 생략되지 않는다; 이는 명시적인 PDF null로 기록되며, ISO 32000-1은 이를 그 좌표에 대해 "뷰어가 이미 가지고 있는 값을 그대로 유지하라"로 취급한다 — 이는 실수가 아니라 "이 페이지로 점프하되 줌은 건드리지 마라"고 말하는 정당한 방법이다. 줌 자체는 넘긴 값의 분수로 저장되므로, 150퍼센트를 요청하는 호출은 배열에 1.5라는 저장된 값을 넘기며, 유효한 입력 범위는 0에서 6400이다

자신의 문서 안에 임베드된 PDF로는 어떻게 링크하는가?

AddLinkToEmbeddedPDF는 GoToE 액션을 만들며, 그 대상 인자인 EmbeddedFileName은 경로가 아니라 이름이다: 이는 첨부파일이 만들어질 때 EmbedFile에 이미 넘겨진 Title 문자열과 일치해야 하는데, 그 title이 PDFlibPas가 문서의 /EmbeddedFiles 이름 트리에 저장하는 문자 그대로의 키이며, GoToE는 파일시스템을 다시 건드리는 것이 아니라 그 이름을 찾아봄으로써 해석되기 때문이다. 이 함수는 EmbeddedFileName이 비어 있지 않고 TargetPage가 최소 1인지만 확인한다 — 실제로 임베드된 적 없는 이름을 넘겨도 호출은 여전히 성공을 반환하고, 액션은 여전히 기록되며, 그것을 클릭하는 모든 리더에 대해 링크는 그저 해석에 실패한다

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;
    Lib.NewPage;
    // The Title argument becomes the key PDFlibPas stores in the
    // document's EmbeddedFiles name tree -- that string, not
    // "datasheet.pdf", is the target GoToE resolves against.
    if Lib.EmbedFile('Datasheet', 'datasheet.pdf', 'application/pdf') = 1 then
      Lib.AddLinkToEmbeddedPDF(72, 700, 200, 16, 'Datasheet', 3, 0, 0);
    Lib.SaveToFile('manual.pdf');
  finally
    Lib.Free;
  end;
end;

여기서는 하나가 아니라 두 개의 버전 하한이 쌓인다. EmbedFile/EmbeddedFiles 이름 트리를 위해 PDF 1.4가 필요하고, AddLinkToEmbeddedPDF는 GoToE 액션 유형 자체를 위해 별도로 그 하한을 PDF 1.6으로 끌어올린다. 그래서 이 기능을 사용하는 어떤 문서든 실효 최소값은 1.4가 아니라 1.6이다. 여기서 TargetPage가 일반적인 PDFlibPas 관례인 1부터 시작한다는 것도 주목하라 — 앞 절에서 방금 다룬 0부터 시작하는 DestPage와 의도적으로 대조되며, 어느 페이지 번호 체계가 적용되는지는 하나의 포괄적인 규칙이 아니라 액션 종류와 특정 호출에 달려 있다는 것을 상기시킨다. 액션의 대상 딕셔너리는 자식을 뜻하는 C나 부모를 뜻하는 P/R 항목도 가질 수 있으며, 임베디드 파일로의 두 단계 체인이나 그 컨테이너로 되돌아가는 것을 지원한다. 다만 AddLinkToEmbeddedPDF는 항상 자식 방향만 만드는데, 이것이 임베드되는 쪽이 아니라 임베드하는 문서 쪽에서 말이 되는 방향이기 때문이다

Launch 액션: 하나의 FileName, 서로 바꿔 쓸 수 없는 두 문자열 대상

SetActionLaunchOptions는 단일 FileName 인자로부터 Launch 액션의 파일 대상을 두 개의 다른 키에 쓰며, 그 두 키는 두 가지 다른 종류의 문자열을 담는다. 최상위 /F 키는 GoToR에 쓰이는 것과 같은 경로 변환을 거쳐 만들어진 파일 지정 딕셔너리를 받는데, 이는 ISO 32000-1 §7.11.3이 파일 지정 딕셔너리를 위해 정의하는 이식 가능한 형태다. PDFlibPas가 /Win 서브 딕셔너리를 쓸 때, 그것은 전혀 변환 없이 넘겨진 그대로의 원시 FileName 값으로 설정된 자신만의 /F 키를 받는데, /Win /F는 ISO 32000-1 §12.6.4.5에서 오직 Windows 뷰어가 읽도록 의도된 순수한 Windows 경로 문자열로 문서화되어 있기 때문이다. 두 키가 결국 동일해질 것으로 기대하며 이식 가능한, 이미 변환된 경로를 넘기면 /Win 사본은 여러분이 이 함수에 건넨 것을 손대지 않고 그대로 담을 것이다

var
  Lib: TPDFlib;
  ActionID: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('manual.pdf', '') = 1 then
    begin
      Lib.SelectPage(1);
      Lib.AddLinkToLocalFile(72, 660, 220, 16, 'calibrate.exe', 0);
      ActionID := Lib.GetAnnotActionID(1);
      // Operation 0 leaves this as a normal open -- pass 1 to ask a
      // Windows viewer to print instead. Parameters and
      // DefaultDirectory only ever reach /Win /P and /Win /D, never
      // the top-level /F.
      Lib.SetActionLaunchOptions(ActionID, 'calibrate.exe',
        '/silent /profile:default', 'C:\Tools\Calibration', 0, -1);
    end;
  finally
    Lib.Free;
  end;
end;

Launch를 세 가지 중 가장 마찰이 큰 액션으로 취급하라. 그 전체 목적이 PDF 샌드박스 밖에서 프로그램을 실행하거나 파일을 여는 것이며, 모든 주류 뷰어가 그에 걸맞게 취급하기 때문이다. Adobe Acrobat의 향상된 보안은 대상이 명시적으로 신뢰된 위치에 있지 않은 한 기본적으로 Launch 액션을 차단하거나 확인을 요청하며, 대부분의 엔터프라이즈 Acrobat 배포는 그 보호를 켜둔 채로 둔다. 그래서 대중에게 넘겨지는 문서 안의 Launch 액션은 신뢰할 만한 트리거가 아니다: 파일을 여는 어떤 뷰어든 이를 차단하거나, 확인을 요청하거나, 조용히 무시할 것으로 계획하고, 여러분이 뷰어의 신뢰 설정도 통제하는 폐쇄된 환경 — 내부 키오스크, 통제된 사내 배포, 여러분이 관리하는 컴퓨터를 절대 벗어나지 않는 문서 — 을 위해 아껴두라

PDF/A 관문: GoToR와 Launch 호출이 0을 반환할 수 있는 이유

SetActionRemoteDestinationExSetActionLaunchOptions 둘 다 대상 문서가 어떤 PDF/A 적합성 모드에 있든 완전히 거부한다: 둘 다 첫 번째 조건으로 문서의 PDF/A 모드를 확인하고, 액션을 건드리기도 전에 예외 없이 0이라는 결과와 함께 빠져나온다. 이는 의도적이다. PDF/A의 인터랙티브 액션 제한은 특히 Launch를 배제하는데, 아카이브 파일에 임의의 프로그램을 실행할 능력을 주는 것은 정확히 장기 아카이브 형식이 존재하는 이유인 환경 의존적 동작이기 때문이며, PDFlibPas는 같은 코드 경로에서 원격 이동 세터에도 같은 보수적인 관문을 적용한다. 실무적인 결과는 개발 도중 놓치기 쉽다: 평범한 PDF에서 작동하는 것과 동일한 호출이 PDF/A 적합성 레벨이 설정된 문서에서는 컴파일되고 실행되면서도 조용히 아무 일도 하지 않는다. 그러니 성공을 가정하는 대신 반환값을 확인하라 — 여기서의 0은 잘못된 형식의 입력 오류가 아니라, 문서 자신의 적합성 주장과 충돌하는 요청을 라이브러리가 거부하는 것이다

GoToR, GoToE, Launch가 더 큰 PDFlibPas 워크플로 안에서 자리하는 곳

이 글의 세 가지 액션 종류가 모두 같은 곳에 도달하는 것은 아니다. 문서 및 페이지 생명주기 액션 트리거에 관한 자매 글SetDocumentActionSetPageAction을 다루는데, 이들은 공유되는 PDF_ACTION_BUILDER_REMOTE_DESTINATIONPDF_ACTION_BUILDER_LAUNCH 상수를 통해 GoToR나 Launch 액션을 WillClose 같은 트리거에 연결할 수 있다 — 순수한 URI나 JavaScript 트리거도 함께 다루는 같은 빌더다. GoToE에는 그런 상수가 없고 그 일반 빌더로 가는 경로도 전혀 없다; AddLinkToEmbeddedPDF가 PDFlibPas가 이를 구성하는 유일한 방법이며, 이는 이것을 엄격히 페이지 핫스팟 액션으로만 만들 뿐 결코 문서나 페이지 수준 트리거가 되지 못하게 한다. GoToR와 Launch가 일반 빌더에 도달하는 곳에서는 제어력이라는 절충이 있다: 이는 이름 붙은 원격 대상만을 가리키는 GoToR와 그저 파일 이름과 매개변수만 있는 Launch 액션을 만드는 반면, 이 글에서 다루는 명시적인 페이지·핏 타입 주소 지정과 Windows 특화 launch 옵션은 오직 SetActionRemoteDestinationExSetActionLaunchOptions를 직접 통해서만 도달할 수 있다

이 세터들 주위에 유지보수 도구를 만들기 전에 알아둘 가치가 있는 안전성 속성이 하나 있다. SetActionRemoteDestinationExSetActionLaunchOptions는 먼저 스크래치 딕셔너리 안에 전체 대체 액션을 만들고, 그 스크래치 사본이 검증된 뒤에야 /F, /D/Win, /NewWindow 키를 삭제하고 살아있는 액션 위에 복사한다 — 그래서 범위를 벗어난 ValueMask든 빈 FileName이든 검증에 실패하는 호출은 원래 액션과 이미 그것에 매달려 있던 어떤 /Next 체인이든 반쯤 덮어써지는 대신 완전히 건드리지 않은 채로 남긴다. 이것이 중요한 이유는 GoToR와 Launch 액션 모두 AddActionNextRemoteDestinationEx, AddActionNextLaunchEx, 또는 더 일반적인 AddActionNextEx로 만들어진 /Next 체인 안에 자리할 수 있기 때문이며, 단일 트리거가 JavaScript 로그 항목을 발생시킨 뒤 순서대로 원격 점프를 발생시킬 수 있게 해준다. 여기서 설명한 GoToR, GoToE, Launch 구성은 Delphi와 C++Builder용 네이티브 PDF 라이브러리인 PDFlibPas의 일부다