기술 문서

PDFium으로 Delphi에서 원자적 PDF 폼 일괄 채우기

Delphi용 PDFium Component는 TPdf.SetFormFieldValues로 AcroForm을 원자적으로 채운다. 페이지와 위젯으로 주소가 매겨진 1부터 256개의 타입화된 요청을 넘기면 컴포넌트는 모든 값을 하나의 문서 치환으로 적용하거나 아무것도 적용하지 않는다. 요청 하나가 무효면 TPdfFormValuesWriteReport.FailedRequest가 배치를 가라앉힌 요청을 이름으로 부른다

이 보장이 가장 빛나는 곳은 아무도 지켜보지 않는 작업이다. 야간 임포트가 CRM 행을 청구 양식에 밀어 넣거나, 인쇄 서비스가 보험 데이터를 미인쇄 템플릿 위에 겹쳐 찍는다. 필드 단위로 돌리면 첫 나쁜 값, 즉 유효한 radio export가 아닌 코드나 필드 MaxLen보다 긴 문자열이 반은 옛 데이터 반은 새 데이터인 PDF를 남긴다. 아무것도 크래시하지 않았으므로 아무도 알림받지 않는다. 배치 API가 존재하는 이유가 실패를 반쯤 기록된 문서가 아니라 깨끗한 거부로 만들기 위해서다

필드 단위 양식 채우기는 왜 데이터 임포트에 위험한가?

필드 단위 채우기가 위험한 이유는 모든 쓰기가 제각각 확정되기 때문이다. 루프 중간의 오류가 원본 레코드와도 원본 템플릿과도 맞지 않는 문서를 남긴다. 고전적 패턴은 FormField[Index] 대입을 도는 루프로, 각 대입이 PDFium의 네이티브 폼 채움 환경을 위젯 하나씩 거친다. 값 1부터 17은 자리를 잡고, 값 18이 예외를 던지면 호출자는 이제 되돌리려면 어떤 값들이 들어갔는지 정확히 알아야 한다

대다수 호출자는 그 되돌리기 코드를 아예 쓰지 않고, 쓰는 쪽도 미묘하게 틀린다. 공유 필드는 이미 자신이 나타나는 모든 페이지에서 바뀌었고, checkbox 상태도 이미 뒤집혔다. 저장이 finally 블록에서 돈다면 반쯤 채워진 파일이 완성처럼 보인 채로 디스크에 간다. overprint 작업에서는 파일이 아예 없는 것보다 더 나쁘다. 인쇄되고 누군가 서명하기 때문이다

또 하나의 조용한 문제는 포커스다. 고전적 setter는 PDFium의 인터랙티브 편집기를 몰아 붙인다. 기존 포커스를 죽이고, 대상 위젯에 포커스를 주고, 전체 텍스트를 선택하고, 교체하고, 포커스를 다시 죽인다. 헤드리스 작업이라면 상관없지만 뷰어에서는 다른 필드에서 열어 둔 편집 세션까지 끝장낸다. PDFium Component 3.231.0에 도착한 타입화된 쓰기 경로는 네이티브 필드 포커스를 전혀 거치지 않고, 3.233.0이 이를 단일 필드에서 페이지를 넘나드는 배치로 확장했다

SetFormFieldValues가 배치를 하나의 치환으로 적용하는 방식

TPdf.SetFormFieldValues는 모든 요청을 문서의 하나의 캡처된 스냅샷에 대해 준비하고, 모든 필드 사전을 한 번에 기록하며, 완성된 결과를 단일 치환으로 맞바꾼다. 원본 문서는 막판까지 살아 있으므로, 그 단계 이전의 어떤 실패도 원본을 건드리지 않는다

Delphi용 PDFium Component의 5단계 원자적 배치 폼 채움 순서, 프리플라이트부터 검증과 맞바꾸기까지 전부 아니면 전무 결과와 함께
모든 요청은 하나의 스냅샷에 대해 준비되며 살아 있는 문서는 최종 검증된 맞바꾸기에서만 건드려진다

이 순서가 보장과 비용을 함께 설명한다

  1. 프리플라이트 검사가 먼저 돈다. 문서가 활성이어야 하고, 요청 수가 1부터 256 사이여야 하며, 페이지 객체나 편집 저널 트랜잭션이 열려 있으면 안 되고, 네이티브 필드가 포커스를 쥐고 있으면 안 되며, 문서가 fill-forms나 modify 권한을 허가해야 한다
  2. 각 요청은 페이지 범위와 입력 예산으로 검증되고, FailedRequest가 검사 중인 인덱스를 추적한다
  3. 컴포넌트는 편집 스냅샷을 캡처하고 그 사본에서 AcroForm을 정규화한 뒤 모든 요청을 순서대로 적용한다. /V, choice 선택에는 /I, 필드를 공유하는 모든 위젯에는 /AS를 기록한다
  4. 변경 사항은 ISO 32000-1 §7.5.6의 의미로 하나의 sparse 증분 업데이트로 스냅샷에 덧붙어 관련 없는 객체는 바이트 그대로 운반된다
  5. 별도 검증기 인스턴스가 후보 바이트를 먼저 로드하고, 깨끗하게 열린 후에야 컴포넌트가 살아 있는 문서를 내려 새 문서를 올린다. 그 로드가 실패하면 원본 스냅샷으로 폴백한다

내부 치환 단계는 컴포넌트가 편집 저널 스냅샷에 쓰는 것과 같은 기계 장치이므로 실행 취소와 크래시 복구 저널과 배치 쓰기는 겹칠 수 없다. 저널 트랜잭션을 먼저 끝내거나 취소하라. 문서가 맞바뀌는 동안 자동 수명 주기 동작은 억압되므로, 문서 열기 스크립트가 치환 한가운데서 필드를 다시 쓰거나 포커스를 낚아챌 수 없다

Delphi에서 타입화된 요청 배치 만들기

배치는 TPdfFormValueRequest 레코드의 동적 배열이며, 각 레코드는 1 기반 Page, 0 기반 페이지 로컬 FieldIndex, 타입화된 TPdfFormValue를 담는다. 값 레코드는 fvkText, fvkChoice, fvkCheckBox, fvkRadioButton 중 하나의 Kind를 갖고, 그 유형에 따라 Text, ChoiceIndices, Checked 멤버를 쓴다

임포트 데이터는 대개 필드 이름을 키로 도착하므로 첫 작업은 이름을 페이지와 위젯 주소로 번역하는 것이다. FieldIndex는 FormFieldCount와 FormFieldInfo와 같은 순서로 페이지의 위젯 어노테이션을 세므로 단순 스캔이 통한다. 헬퍼는 이름에 대해 처음 발견한 위젯을 취하는데, 이것이 공유 필드에서 중요하다. 다음 절이 설명한다

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // 첫 위젯만: 필드당 요청 하나
      end;
  end;
end;

procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

AddRequest는 모르는 이름을 건너뛰는 대신 예외를 던진다. 배치는 여러분의 매핑이 아니라 주소를 검증하므로, 템플릿에 없는 필드는 여기서 실패해야 한다. 그렇지 않으면 영영 실패하지 않는다. 주소가 풀렸으면 값 만들기와 확정은 짧다

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // 선택할 버튼의 export 값
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // 필드 Opt 배열의 0 기반 인덱스
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
      Pdf.SaveAs('claim-filled.pdf')
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

타입화된 유형은 무엇이든 기록되기 전에 필드 유형과 대조된다. checkbox를 겨냥한 텍스트 값, 평범한 checkbox를 겨냥한 radio 값, push button에 대한 쓰기는 모두 거부된다. choice 인덱스는 유효하고 유일하며 오름차순이어야 하고, 둘 이상의 인덱스는 MultiSelect 플래그를 요구하며, choice 필드의 자유 텍스트는 편집 가능한 combo box만 받는다. 필드 MaxLen보다 긴 텍스트 값은 잘리는 대신 거부되고, 읽기 전용 필드는 즉시 거부한다. NoToggleToOff 플래그가 붙은 radio 그룹에서는 빈 export 값, 그러니까 '그룹 비우기'도 거부된다

공유 필드를 두 번 쓰면 왜 배치 전체가 실패하는가?

TPdf.SetFormFieldValues는 같은 터미널 필드로 풀리는 요청 둘을 거부한다. 둘은 단일 값을 기록하려 들고, 컴포넌트는 한 요청이 다른 요청을 조용히 덮어쓰게 두지 않는다. 검사는 위젯이 아니라 필드 소유자에서 돌므로, 두 페이지에 흩어진 한 필드의 서로 다른 위젯 둘도 여전히 중복으로 센다

이유는 ISO 32000-1 §12.7.3.1의 필드 모델에 있다. 터미널 필드가 값을 소유하고, 위젯 어노테이션은 그 값이 표시되는 장소들일 뿐이다. 모든 페이지 머리말에 나타나는 PolicyNumber 필드는 위젯 여럿을 지닌 하나의 필드이고, 그 위젯 전부가 같은 /V를 보여 준다. radio 그룹도 같은 식이다. 각 버튼이 위젯이고 그룹이 필드다. PolicyNumber를 페이지 1 위젯으로 한 번, 페이지 9 위젯으로 또 쓰는 것은 한 필드를 두 번 쓰는 일이며, 승자를 가려 주는 어떤 규칙도 여러분 매핑의 버그를 숨긴다

그래서 배치는 이것을 오류로 취급하고 두 번째 요청을 FailedRequest로 지명한다. 실전에서 수정은 상류에서 이뤄진다. FindFieldAddress가 하듯 각 필드 이름을 정확히 하나의 주소로 풀고, 컴포넌트가 값을 다른 위젯들로 전파하게 하라. 컴포넌트는 쓰기의 일부로 그렇게 하며, 공유 checkbox나 radio 그룹의 모든 위젯에 /AS를 설정해 appearance 상태가 필드 값과 일치하게 만든다. checkbox 필드 값과 위젯 상태에 관한 글이 다루는 바로 그 분업이다

하나의 터미널 필드가 값을 소유하고 두 페이지의 위젯이 표시하며, 중복 요청은 Delphi용 PDFium Component에서 배치를 실패시킨다
터미널 필드마다 요청 하나 — 컴포넌트는 /V를 한 번 쓰고 그것을 공유하는 모든 위젯에 /AS를 설정한다

실패 리포트 읽기

실패한 배치는 False를 돌려주고, 예외를 던지지 않으며, 원본 문서와 모든 필드 값을 그대로 남긴다. TPdfFormValuesWriteReport는 이어서 Succeeded = False, UpdatedFields와 UpdatedWidgets 모두 0, FailedRequest의 문제 요청 0 기반 인덱스, ErrorMessage의 사람이 읽을 이유를 보고한다

procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

-1인 FailedRequest는 거부가 요청이 아니라 트랜잭션에 속한다는 뜻이다. 네이티브 포커스가 활동 중이거나, 문서가 서명돼 있거나, 폼이 XFA이거나, 권한 검사가 실패한 경우다. 0 이상 값은 한 항목을 가리킨다. 집계 예산 초과에서는 배치를 한도 위로 밀어 넘긴 요청을 가리키는데, 반드시 틀린 요청일 필요는 없다. Requests 곁에 원본 열 이름의 평행 배열을 두면 임포트 로그에서 인덱스 14를 곧장 '행 2087, 열 ShipDate'로 바꿀 수 있다

성공하면 UpdatedFields가 요청 수와 같아지고 UpdatedWidgets는 공유 위젯까지 포함해 손대진 모든 위젯을 센다. 템플릿에 대한 싼 건전성 검사다

부딪히게 될 전제 조건과 한도

뷰어 애플리케이션에서 가장 흔한 거부는 네이티브 포커스다. 사용자가 필드에 입력 중이라면 SetFormFieldValues는 스냅샷을 캡처하기도 전에 거부하고, 대기 중인 편집기 버퍼는 온전히 살아남는다. 컴포넌트는 여러분 몰래 포커스를 내려놓지 않는다. 사용자가 아직 끝내지 않은 텍스트가 확정되거나 잃어버리게 되기 때문이다. ClearFormFieldFocus를 일부러 호출하라. 필드 밖을 클릭했을 때처럼 편집기 세션을 끝맺고 그다음 재시도하라

예산은 고정돼 있고 문서화돼 있으므로 임포트 규모를 이에 맞출 수 있다

  • 배치당 요청 1부터 256개. 더 큰 레코드는 여러 배치로 갈라 놓고 원자성이 배치 단위로 유지된다는 점을 받아들여라
  • 요청당 텍스트는 최대 262,144 UTF-16 유닛, choice 인덱스는 최대 4,096개
  • 배치 전체로는 텍스트 최대 1,048,576 UTF-16 유닛, choice 인덱스 최대 16,384개
  • 스냅샷 파서의 평범한 문서 예산도 여전히 적용된다. 64 MiB 원본과 65,536 객체 포함

성공한 치환은 또한 호출 전에 얻은 네이티브 핸들, 검색, 선택을 무효화한다. 밑바탕의 문서가 새것이기 때문이다. 페이지와 뷰 활성화, 논리 필드 커서는 유지된다. 어노테이션이나 페이지 객체 핸들처럼 그 밖에 캐시해 둔 것은 다시 가져와야 한다

XFDF 임포트와는 어떻게 다른가?

TPdf.ImportXFDF와 TPdf.SetFormFieldValues는 둘 다 필드에 데이터를 넣지만, XFDF 임포트는 관대한 병합이고 배치는 엄격한 트랜잭션이다. XFDF 임포트는 값을 이름으로 필드에 대응시키고, 찾지 못한 이름은 건너뛰고, 중복을 무시하며, 몇 항목을 적용했는지 돌려준다. 검토자의 코멘트를 병합할 때는 부분 결과도 쓸모 있으므로 정확히 옳은 성격이다

배치는 모든 점에서 정반대다. 위젯을 명시적으로 주소 지정하고, 모르거나 유형이 틀린 대상은 거부하며, 중복을 오류로 치고, 부분 결과라는 것이 없다. 데이터가 다른 도구의 교환 파일이면 XFDF를 쓰고, 매핑을 여러분 코드가 소유하며 반쯤 적용된 레코드가 결함이 되는 경우에는 SetFormFieldValues를 써라

Delphi용 PDFium Component에서 XFDF 임포트와 원자적 SetFormFieldValues 배치를 비교하는 결정 매트릭스: 대상 지정, 미지·중복 대상, 결과, 최적 용도
XFDF는 교환 파일을 관대하게 병합하고 배치는 미지 이름, 중복, 부분 결과를 거부한다

배치 쓰기가 커버하지 않는 것

배치는 경계가 있는 AcroForm 프로파일이며, 프로파일 밖에서는 추측하는 대신 no라고 말한다. XFA 양식은 거부되며 전용 편집기가 필요하다. 서명된 문서는 거부된다. 기존 서명 아래에서 필드 사전을 다시 쓰는 것은 채움 연산이 아니라 서명 워크플로이고, 암호화된 스냅샷도 프로파일 밖이다. 잘못되거나 모호한 버튼 export 배열은 거부되고, 둘 이상의 버튼과 맞는 radio export 값은 그룹이 RadiosInUnison을 설정하지 않는 한 거부된다. 트랜잭션은 또한 기록 전에 direct 필드 사전을 정규화하는데, 되돌아가 보이는 필드 값의 배경 사안이다

appearance에는 한마디의 주의가 필요하다. 바뀐 텍스트와 choice 위젯에 대해 배치는 옛 값을 보여 주는 appearance를 출하하는 대신 낡은 /AP를 버리고, PDFium이 폼의 무관한 위젯 전부를 다시 만들지 않도록 NeedAppearances를 끈다. PDFium은 렌더링할 때 빠진 appearance를 재생한다. 채워진 PDF가 appearance를 절대 합성하지 않는 엄격한 인쇄 파이프라인으로 간다면 먼저 PDFium으로 렌더링하거나 플래튼하고, 그 경로를 여러분 템플릿으로 테스트하라

SetFormFieldValues와 타입화된 값 레코드는 렌더링, 편집, 뷰어 컨트롤과 함께 Delphi 및 Lazarus용 PDFium Component에 들어 있으며, 제품 페이지에 폼 채움과 AcroForm API 전체가 정리돼 있다