Technical Article

Delphi의 대화형 PDF 폼: 액션 및 JavaScript

PDF 폼 필드 자체는 값을 보관하는 상자에 불과합니다. 폼이 소형 애플리케이션처럼 작동하도록 만드는 것은 필드에 연결된 액션입니다. 특정 섹션을 숨기거나, 파일에서 저장된 값을 불러오거나, 마지막 페이지로 이동하거나, 열의 합계를 구하는 스크립트를 실행하는 등의 동작이 클릭 한 번으로 수행됩니다. 이러한 동작 중 어느 것도 필드 자체에 포함되어 있지 않습니다. 이들은 액션 딕셔너리에 정의되어 있으며, ISO 32000-1의 §12.6에서 이 전체 제품군을 정리하고 있습니다. 이 글에서는 Delphi 프로그램에서 가장 자주 사용하는 액션들을 살펴보고, PDFlibPas가 각각을 필드나 링크에 어떻게 연결하는지 보여줍니다.

명심해야 할 멘탈 모델은 필드와 액션이 참조로 연결된 별개의 객체라는 점입니다. 위젯 주석(widget annotation)이나 링크 주석(link annotation)은 /A 항목에 액션을 포함합니다. 액션은 인덱스가 아닌 타이틀로 작동 대상 필드를 명명하므로, 필드에 부여한 타이틀은 이후 모든 액션이 해당 필드를 찾기 위해 사용하는 핸들이 됩니다. 이러한 구분이 명확해지면, API는 무작위로 섞인 호출의 집합이 아니라 네 가지 종류의 동작에 적용된 하나의 패턴으로 보이기 시작합니다.

명명된 액션: 페이지 번호 없는 탐색

가장 단순한 액션은 매개변수를 전혀 가지지 않습니다. ISO 32000-1 §12.6.4.11, Table 194는 Named 액션을 정의합니다. 뷰어는 저장된 대상을 따라가는 대신 런타임에 심볼릭 이름을 해석합니다. 네 가지 이름이 보편적으로 지원되며, 이는 독자가 툴바에서 기대하는 것과 정확히 일치합니다: NextPage, PrevPage, FirstPage, LastPage. 대상 페이지가 뷰어가 현재 표시하고 있는 페이지를 기준으로 하기 때문에, 이 방식으로 빌드된 '다음' 버튼은 사용자가 대상을 계산할 필요 없이 모든 페이지에서 작동합니다.

In PDFlibPas a named action is attached to a hotspot rectangle on the current page. The fourth and fifth integer arguments select the verb and the appearance.

// NamedActionType: 0 = NextPage, 1 = PrevPage, 2 = FirstPage, 3 = LastPage
// Options bit 0 (value 1) draws a border around the hotspot
Pdf.AddLinkToNamedAction(500, 560, 60, 18, 0, 1);   // Next
Pdf.AddLinkToNamedAction(40, 560, 60, 18, 1, 1);    // Previous
Pdf.AddLinkToNamedAction(110, 560, 60, 18, 3, 1);   // jump to last page

동기화 상태를 유지해야 할 대상이 없다는 것이 이 방식의 핵심입니다. Named 액션은 애초에 페이지를 명명하지 않기 때문에 페이지 삽입 및 삭제에도 유지됩니다. 문서가 커지는 순간 번호를 다시 매겨야 하는 대상 페이지 인덱스를 저장하는 명시적인 go-to 링크와 비교해 보시기 바랍니다.

Hide 액션과 배열 처리 시의 주의 사항

Hide 액션(ISO 32000-1 §12.6.4.10, Table 196)은 하나 이상의 필드 표시 여부를 토글합니다. 이는 스크립팅 없이 보이기/숨기기 동작을 빌드하는 가장 깔끔한 방법이며, '상세 정보 표시' 링크나 하나를 나타내면 다른 하나가 숨겨지는 상호 배타적인 두 개의 패널에 매우 적합합니다. 액션은 /T 항목에 대상을 포함하고, true일 때 숨기고 false일 때 표시하도록 결정하는 부울 값 /H를 가집니다.

이 동작의 미묘함은 전적으로 대상이 인코딩되는 방식에 있으며, 이는 개발자의 컴퓨터에서는 작동하지만 고객의 컴퓨터에서는 실패하는 폼을 유발하기 쉬운 세부 사항입니다. 액션이 단일 필드를 지정할 때 /T는 하나의 텍스트 문자열로 기록됩니다. 여러 필드를 지정할 때는 /T가 텍스트 문자열의 배열로 기록됩니다. 구형 뷰어는 단일 요소 배열을 일반 문자열과 동일하게 취급하지 않으므로, 가능한 많은 리더가 이를 지원하도록 하려면 개수에 따라 인코딩을 분기해야 합니다: 단일 이름은 길이 1의 배열이 아닌 일반 문자열로 출력되어야 합니다. PDFlibPas는 이 결정을 대신 처리해 줍니다. 쉼표, 세미콜론 또는 개행 문자로 구분된 필드 이름을 전달하면, 라이터(writer)가 단일 이름에 대해서는 단일 문자열을, 둘 이상에 대해서는 배열을 출력합니다.

// HideFlag non-zero hides the listed fields (/H true); zero shows them.
// One name -> /T is a text string. Two or more -> /T is an array of strings.
Pdf.AddLinkToHideField(40, 700, 90, 18, 'ShippingAddress', 1, 1);
Pdf.AddLinkToHideField(140, 700, 90, 18,
  'ShippingName,ShippingAddress,ShippingZip', 1, 1);

이 액션은 외부 리소스를 참조하지 않으므로 PDF/A와의 호환성을 유지합니다. 전달하는 이름은 정규화된 필드 타이틀(fully qualified field title)이므로, 그룹 내의 하위 필드는 단독 리프 이름이 아닌 점(dot)으로 구분된 전체 경로를 통해 주소를 지정해야 합니다.

ImportData: FDF에서 미리 채우기

Hide 액션이 이미 페이지에 있는 요소를 재배치하는 반면, ImportData 액션은 외부에서 값을 가져옵니다. ISO 32000-1 §12.6.4.8, Table 198은 디스크의 FDF(Forms Data Format) 파일에서 AcroForm을 채우는 액션으로 이를 정의합니다. 이는 '샘플 데이터 다시 로드' 또는 '기본값으로 재설정' 컨트롤의 기반이 되는 액션으로, FDF 파일이 PDF와 함께 제공되어 표준 필드 값을 보관합니다. 이 호출은 다른 호출들과 유사하게 핫스팟 직사각형, FDF 경로 및 모양 비트마스크를 사용합니다: Pdf.AddLinkToImportData(40, 660, 120, 18, 'defaults.fdf', 1). 파일이 존재할 필요는 없지만 사용자가 클릭할 때 존재해야 하며, 경로의 모든 백슬래시는 PDF 표준인 슬래시 형식으로 자동으로 변환됩니다.

자주 혼동을 주는 제약 조건 중 하나는 확실히 짚고 넘어갈 필요가 있습니다. ImportData 액션은 외부 파일을 가리키므로 PDF/A에서는 허용되지 않습니다. 문서가 PDF/A 모드일 때 이 호출은 검증에 실패하는 파일을 생성하는 대신 0을 반환하고 아무것도 추가하지 않습니다. 파이프라인의 목표가 아카이브 출력인 경우, 사전 채우기는 클릭 시점으로 미루지 말고 생성 시점에 필드 값을 직접 작성하여 수행해야 합니다.

JavaScript: 글로벌 패키지 및 액션별 스크립트

보이기, 숨기기, 가져오기 이상의 로직을 구현하기 위해 액션 제품군은 문서 레벨 JavaScript를 활용합니다. 스크립트가 위치할 수 있는 고유한 위치는 두 군데이며, 그 차이를 이해하는 것이 중요합니다. 문서 레벨 JavaScript 패키지는 전체 파일에 대해 한 번 저장되고 문서가 열릴 때 실행되므로, 함수 정의와 공유 상태를 보관하기에 적합합니다. 반면 액션별 스크립트는 단일 링크나 필드에 첨부되어 해당 객체가 활성화될 때만 실행되므로, 패키지에 정의된 함수를 호출하는 단일 라인을 두기에 적합합니다.

PDFlibPas exposes both. AddGlobalJavaScript는 문서 레벨에 명명된 패키지를 저장하며, 이름을 재사용하면 기존에 저장되어 있던 내용을 덮어씁니다. AddLinkToJavaScript는 클릭 시 실행되도록 핫스팟에 스크립트를 첨부합니다.

// Document-level package: define a reusable function once.
Pdf.AddGlobalJavaScript('Totals',
  'function recalcTotal() {' +
  '  var net = this.getField("Net").value;' +
  '  var tax = this.getField("Tax").value;' +
  '  this.getField("Gross").value = Number(net) + Number(tax);' +
  '}');

// Per-action script on a link: just call the shared function.
Pdf.AddLinkToJavaScript(40, 620, 100, 18, 'recalcTotal();', 1);

글로벌 패키지에 함수를 정의하고 링크에서 이를 호출하는 구조는 단순한 스타일 선호의 문제가 아닙니다. 이는 해당 함수가 필요한 모든 컨트롤에서 동일한 바디를 중복 정의하는 것을 방지하며, 스크립팅이 비활성화된 뷰어에서도 잘못된 인라인 블롭으로 인해 오류가 발생하는 대신 클릭 시 아무 동작도 하지 않도록 처리합니다. 또한 액션별 항목을 작게 유지하여 나중에 파일을 검사할 때 가독성을 높여줍니다.

필드, 하위 필드 및 결과 고정

액션이 작동하려면 대상 필드가 필요하므로, 필드가 어떻게 생성되는지 살펴보는 것이 좋습니다. NewFormField는 현재 페이지에 필드를 생성하고 해당 인덱스를 반환합니다. 정수 유형에 따라 필드 종류가 결정되며, 1은 Text, 2는 Pushbutton, 3은 Checkbox, 4는 Radiobutton, 5는 Choice, 6은 Signature, 7은 자식을 가지지만 스스로는 아무것도 그리지 않는 Parent입니다. 전달하는 타이틀에는 마침표(period)를 포함할 수 없습니다. 마침표는 액션이 하위 필드를 참조할 때 사용하는 정규화된 이름의 구분자로 사용되기 때문입니다.

라디오 그룹 및 계층 구조 폼은 부모 필드에 자식을 제공하여 구축됩니다. NewChildFormField는 명명된 부모 아래에 하위 필드를 추가하며, 라디오 및 초이스 케이스의 경우 AddFormFieldSub가 개별 옵션을 추가하고 각각의 위치를 지정하는 데 사용할 임시 인덱스를 반환합니다. 대화형 단계가 끝나고 필드의 현재 모양을 영구적인 페이지 콘텐츠로 고정하려는 경우, FlattenFormField는 필드를 페이지에 그린 후 폼에서 제거합니다. 플래튼(flatten) 작업 후에는 이후 필드들의 인덱스가 하나씩 앞으로 밀리므로, 루프에서 여러 필드를 플래튼할 때 이 점을 유의해야 합니다.

var
  Pdf: TPDFlib;
  FldShip: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    Pdf.SetOrigin(1);          // top-left origin
    Pdf.SetPageSize('A4');
    Pdf.NewPage;

    // A text field the Hide action will target by its title.
    FldShip := Pdf.NewFormField('ShippingAddress', 1);
    Pdf.SetFormFieldBounds(FldShip, 40, 120, 240, 20);
    Pdf.SetFormFieldValue(FldShip, '');

    // Wire a Hide link and a navigation link to this page.
    Pdf.DrawText(40, 110, 'Toggle shipping block:');
    Pdf.AddLinkToHideField(220, 100, 70, 16, 'ShippingAddress', 1, 1);
    Pdf.AddLinkToNamedAction(500, 800, 60, 18, 3, 1);  // Last page

    // A document-level script available to every event in the file.
    Pdf.AddGlobalJavaScript('OnOpen',
      'app.alert("Form ready", 3);');

    // Freeze the field if the output should no longer be editable.
    // Pdf.FlattenFormField(FldShip);

    if Pdf.SaveToFile('form_actions.pdf') <> 1 then
      raise Exception.Create('Save failed');
  finally
    Pdf.Free;
  end;
end;

플래튼 호출은 의도적으로 주석 처리되어 있습니다. 이를 생략하면 문서가 리더기에서 액션이 작동하는 라이브 폼으로 빌드됩니다. 반대로 활성화하면 필드가 정적 마크로 렌더링되는데, 이는 폼 작성이 완료되어 결과를 고정된 레코드로 전달하고자 할 때 적합합니다. 고정 여부에 따라 동일한 필드와 코드로 완전히 다른 두 가지 문서가 생성됩니다.

올바른 동사 선택

네 가지 액션은 제어하는 대상에 따라 명확하게 구분됩니다. Named 액션은 뷰포트를 이동하며 필드가 필요하지 않습니다. Hide 액션은 가시성을 변경하며 필드 타이틀이 필요하고, 문자열 대 배열 인코딩은 내부적으로 처리됩니다. ImportData 액션은 디스크의 파일에 액세스하므로 PDF/A에서는 제한됩니다. JavaScript 액션은 임의의 로직을 실행하며, 함수를 포함하는 글로벌 패키지와 소형 액션별 호출로 분할하는 것이 가장 좋습니다. 작업을 수행하는 가장 간단한 액션을 사용해 보십시오: Hide 액션은 숨김 플래그를 설정하는 스크립트보다 호환성이 높고, Named 액션은 유지 관리할 페이지 번호가 없으므로 저장된 페이지 대상보다 안정적입니다.

여기서 연관된 두 가지 주제가 전체적인 작동 원리를 완성합니다. 폼이 접근성 문서의 일부인 경우, 스크린 리더가 읽는 구조 트리는 태그가 지정된 PDF 및 접근성 구조에 대한 문서에서 다룹니다. 작성된 폼을 잠그고 서명해야 하는 경우의 워크플로는 규정 준수 및 서명 워크벤치 연습 가이드에 설명되어 있습니다. 세 가지 모두 이 블로그의 다른 곳에서 다루는 생성, 폼, 서명 API와 함께 Delphi용 PDF 라이브러리로 제공되는 동일한 엔진을 기반으로 구축되었습니다.