기술 문서

PDFium Component Dynamic XFA: 페이지 수는 델타

Delphi 뷰어의 다이내믹 XFA 폼이 페이지를 더하거나 뺄 때, PDFium Component는 v3.126.1부터 TPdf.PageCount와 TPdf.OnXfaPageCountChanged로 새 총계를 보고합니다. 네이티브 페이지 이벤트는 총계가 아니라 추가/삭제 델타를 실어 다니기 때문입니다. v3.126.1의 Windows V8 라이브러리는 이동한 필드와 함께 입력 히트 영역도 옮기고, v3.126.2는 레이아웃 콜백이 돌아온 뒤 낡은 페이지 핸들을 다시 로드합니다. 이것을 시작시킨 버그 리포트는 경비 청구 폼이었습니다. Add Row를 두 번 클릭하면 폼이 두 페이지로 자라는데 페이지 표시기는 자랑스럽게 1/1을 읽습니다. 2페이지로 이동한 필드에 입력하면 키 입력은 보이지 않는 어딘가에 떨어집니다. 모두가 먼저 테스트하는 고정 길이 샘플 폼에서는 아무것도 드러나지 않았는데, 폼 뷰어를 내장한다면 그 이유를 알아 둘 가치가 있습니다

다이내믹 XFA 폼이 재페이지매김하면 무엇이 일어날까?

다이내믹 XFA 폼에는 고정된 페이지 목록이 없으므로, 페이지 수는 레이아웃의 출력이며 사용자가 데이터를 편집할 때마다 바뀔 수 있습니다. XFA 3.3은 폼을 서브폼의 트리로 기술합니다. 반복 서브폼은 instanceManager가 제어하고 _Row.addInstance() 같은 스크립트가 행을 하나 더 복제합니다. 그러면 레이아웃 프로세서가 콘텐츠를 페이지 영역에 다시 흘려 넣는데, 페이지를 더하거나 없애거나 기존 필드를 다른 페이지로 밀어 낼 수 있습니다. ISO 32000-1 §12.7.8은 XFA 패킷이 PDF 안에서 어떻게 실리는지만 정의합니다. 그다음에 일어나는 모든 것은 XFA 엔진의 소관이며, PDFium Component에서 그것은 호스트 프로세스에서 도는 PDFium 자체의 XFA 레이아웃입니다. 그러므로 Delphi 뷰어는 페이지 수, 페이지 크기, 위젯 위치가 모두 살아 있는 상태인 문서를 다룹니다. 호스트가 그렇지 않다고 가정하면 세 가지가 잘못됩니다:

  • 호스트가 탐색, 스크롤 범위, 페이지 스피너를 위해 캐시하는 페이지 수가 낡아지거나, 더 나쁘게는 잘못된 숫자로 갱신됩니다
  • 이동한 필드는 테두리를 새 위치에 보여 주는 동안 편집기와 마우스 히트 영역은 옛 좌표에 남습니다
  • 뷰어는 레이아웃이 교체해 버린 페이지 핸들을 계속 쥐고 있으므로, 클릭과 그리기는 그 폼에서 더 이상 존재하지 않는 페이지로 갑니다

행 편집을 저장과 다시 열기에 걸쳐 영속하는 것은 자기 규칙이 있는 별개 문제입니다. 이 글은 뷰어 안에서 런타임에 일어나는 일에 머뭅니다

다이내믹 XFA는 어떤 PDFium 런타임을 필요로 할까?

PDFium Component의 다이내믹 XFA는 네이티브 라이브러리의 V8/XFA 빌드를 필요로 하며, 첫 문서가 로드되기 전에 PDFium 유닛의 전역 EnableV8Engine 변수가 고릅니다. 프로세스는 어떤 TPdf든 라이브러리를 처음 로드할 때 하나의 DLL에 정착하고, 평범한 PDFium 빌드는 XFA 엔진을 아예 돌릴 수 없습니다. 문서를 열 때 TPdf는 파일에서 XFA 마커를 살짝 들여다보고 V8 빌드로 자동 전환하기도 하지만, 그 프로세스에서 아직 평범한 라이브러리가 로드되지 않았을 때만입니다. 정착이 이미 잘못된 방향으로 끝났다면 TPdf.OnXfaRuntimeMissing이 한 번 발화해 호스트가 사용자에게 재시작을 안내할 수 있게 해 줍니다. 시작 때 플래그를 명시적으로 설정하면 추측이 사라집니다. XFA 이벤트를 실어 나르는 FPDF_FORMFILLINFO 콜백 구조체도 DLL과 일치해야 합니다. 배경은 FPDF_FORMFILLINFO version 2와 XFA 콜백 ABI에 있고, 뷰어를 열기 전에 폼 타입을 가르는 것은 XFA 폼 탐지와 패킷 읽기가 다룹니다

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // 첫 TPdf가 네이티브 라이브러리를 로드하기 전에 결정:
  // 프로세스는 나중에 pdfium.dll에서 pdfium.v8.dll로 전환할 수 없음
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

PageCount는 두 페이지 폼에 왜 1을 보고했을까?

v3.126.1 전의 PDFium Component는 네이티브 페이지 이벤트의 page_count 인자를 문서 총계로 저장했는데, 그 인자는 실제로는 새 페이지 수와 옛 페이지 수의 절댓값 차이입니다. PDFium은 레이아웃 패스가 끝난 뒤 페이지 추가 또는 페이지 삭제 이벤트 타입으로 FFI_PageEvent를 일으키며, 내부적으로는 자기 저장 페이지 수를 먼저 갱신한 뒤 abs(new - old)를 넘깁니다. 초기 레이아웃에서는 옛 수가 0이므로 델타가 총계와 같고, 세 페이지짜리 정적 샘플은 기대대로 세 페이지를 보고합니다. 고정 길이 테스트 폼이 결코 이 버그를 드러내지 않은 이유가 바로 그것입니다. 다이내믹 폼이 처음 한 페이지에서 두 페이지로 자랄 때 델타는 1이고, 래퍼는 TPdf.PageCount와 OnXfaPageCountChanged의 NewCount 매개변수를 모두 1로 맞췄습니다. 세 페이지 폼에서 행을 지우는 것은 반대 방향으로 같은 종류의 헛소리를 만들어 냈습니다

델타를 이전 값에 누적하는 것도 안전한 수리가 아닙니다. 초기화와 레이아웃 콜백의 순서 때문에 래퍼는 이전 수를 기준선으로 항상 신뢰할 수 없고, 따라서 누적합은 흘러갈 수 있습니다. v3.126.1부터 콜백은 인자를 개수로 무시하고 문서에 FPDF_GetPageCount를 호출하며, 그것은 방금 완료된 레이아웃에서 총계를 읽습니다. 그런 다음 캐시된 페이지 씬을 지우고, 그 총계를 TPdf.PageCount 뒤의 XFA 페이지 수 오버라이드로 저장하고, 그 후에야 OnXfaPageCountChanged를 일으킵니다. 여러분의 핸들러가 돌 때쯤이면 NewCount와 FPdf.PageCount는 일치합니다

PDFium Component 다이내믹 XFA 다이어그램: 행 추가가 한 페이지 폼을 두 페이지로 재페이지매김하고 FFI_PageEvent는 abs(new - old)를 델타로 넘기므로, 옛 래퍼는 TPdf.PageCount 1을 보고했지만 v3.126.1은 FPDF_GetPageCount를 읽어 올바른 총계를 보고함
네이티브 페이지 이벤트는 총계가 아니라 추가 또는 삭제 델타를 보고하므로, v3.126.1은 인자를 무시하고 OnXfaPageCountChanged를 일으키기 전에 완료된 레이아웃을 읽습니다
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+: NewCount는 완료된 레이아웃의 총계이지 결코 델타가 아님.
  // PDFium의 레이아웃 콜백 안에서 돎: 호스트 UI 상태만 갱신하고,
  // 여기서 문서를 닫거나 페이지를 다시 로드하지 말 것
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // 지연된 XFA 리프레시를 포함해 페이지가 다시 로드될 때마다 발화
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

이 이벤트는 레이아웃이 런타임에 바뀌는 Full XFA 폼에만 발화합니다. Static XFA와 AcroForm 문서는 결코 일으키지 않으므로, 둘 다 다루는 뷰어는 같은 핸들러를 연 채로 둘 수 있습니다. 연결하지 않은 채 두는 것도 안전합니다. TPdf.PageCount 뒤의 오버라이드는 어차피 적용되고, 이벤트는 호스트가 캐시한 것을 새로 고칠 수 있게 존재할 뿐입니다

필드가 이동하면 입력 상자는 왜 옛 페이지에 남을까?

테두리는 움직이고 편집기는 움직이지 않은 이유는 네이티브 XFA 노티파이어가 사각형과 자기 자신을 비교했기 때문입니다. 레이아웃이 이미 로드된 위젯의 기하를 바꿀 때 PDFium은 새 사각형을 알아차려 위젯에 PerformLayout을 호출해, 텍스트 편집기와 그 히트 영역을 다시 배치해야 합니다. 그 검사는 GetWidgetRect()를 RecacheWidgetRect()와 비교했는데, 두 함수 모두 같은 멤버에 대한 const 참조를 돌려주고 recache는 그 멤버를 제자리에서 덮어쓰므로, 비교는 언제나 같은 두 값을 보았고 로드된 위젯은 재배치를 건너뛰었습니다

증상은 테스트가 서브폼 높이를 바꿔 기존 필드가 다음 페이지로 걸쳐 가게 했을 때 드러났습니다. 두 V8 아키텍처 모두에서 필드 테두리는 새 위치에 그려지는 동안 입력된 텍스트와 마우스 히트 영역은 이전 Y 좌표에 남았습니다. 명시적 재배치도 고치지 못했고 페이지를 다시 로드해도 안 됐습니다. 위젯은 여전히 자기 기하가 최신이라 믿었으니까요. v3.126.1과 함께 배송된 Windows V8 라이브러리는 recache 전에 옛 사각형을 값으로 복사해 그 사본을 비교하므로, 이동한 위젯은 재배치되고 편집된 값은 정확히 테두리가 있는 곳에 나타납니다. 이것은 네이티브 수정입니다. DLL과 함께 다니므로, Pascal 유닛만 갱신하고 오래된 pdfium.v8.dll을 유지하면 엉뚱한 히트 영역은 제자리에 남습니다. 그것을 이끈 회귀 검사는 살아남은 행을 비기본값으로 먼저 편집한 뒤 그 값이 필드의 새 위치에 있기를 요구합니다. 기본값으로 재구축된 행은 그렇지 않으면 통과처럼 보이니까요

PDFium Component 위젯 재배치 다이어그램: GetWidgetRect와 RecacheWidgetRect가 하나의 공유 멤버를 돌려주어 이동한 위젯이 PerformLayout을 건너뛰던 옛 자기 비교를, 편집기와 마우스 히트 영역을 다시 그려진 테두리 위로 옮기는 v3.126.1 Windows V8 값 복사 검사와 대조함
사각형과 자기 자신을 비교하면 결코 실패하지 않습니다. 그래서 검사가 먼저 값으로 사본을 저장하기 전까지는 테두리가 움직이는 동안 입력된 텍스트와 클릭은 뒤처져 있었습니다

TPdfView는 PDFium의 핸들을 발밑에서 뽑지 않고 페이지를 어떻게 다시 로드할까?

v3.126.2부터 TPdfView는 XFA 레이아웃 변경 뒤에 따라오는 페이지 재로드를 네이티브 호출 스택이 풀릴 때까지 미룹니다. 페이지 이벤트는 보통 PDFium이 아직 입력을 처리하는 동안 발화합니다. 사용자가 Add Row 버튼을 클릭하고, 클릭이 스크립트를 돌리고, 스크립트가 인스턴스 수를 바꾸고, 레이아웃이 그 같은 네이티브 호출 안에서 끝나죠. 그 순간 페이지 핸들을 닫고 다시 열면 호출자가 아직 쓰고 있는 오브젝트를 해제해 버립니다. v3.126.2 전의 뷰어는 자기 자신을 무효화만 했으므로, 표시된 페이지 핸들은 레이아웃 이전 상태를 계속 가리킬 수 있었고, 페이지가 사라질 때 사용자가 마지막 페이지에 있었다면 선택된 페이지 번호는 범위 밖이었습니다

지연 리프레시는 몇 가지 작은 단계로 동작하며, 호스트에서 보는 동작을 설명해 줍니다:

  1. 페이지 이벤트 콜백은 뷰에 대기 중인 XFA 레이아웃 리프레시가 있다고 표시하고 사설 윈도우 메시지를 보냅니다. 메시지가 도착하기 전의 반복 이벤트는 하나의 리프레시로 합쳐집니다
  2. 아직 윈도우 핸들이 없는 뷰는 대기 플래그를 유지하다 CreateWnd에서 메시지를 보내고, 문서를 바꾸거나 뷰를 비활성화하거나 파괴하면 플래그를 지웁니다
  3. 메시지가 도착하면 뷰는 텍스트 선택, 검색 하이라이트, 포커스된 필드 인덱스를 지웁니다. 셋 모두 옛 레이아웃을 가리켰기 때문입니다
  4. 선택된 페이지는 새 PageCount로 클램프됩니다. 바뀐 페이지 번호는 정상 페이지 전환을 거치고, 그렇지 않으면 현재 페이지가 다시 로드되며, 맞춤 모드가 다시 적용됩니다
  5. 레이아웃이 페이지를 아예 남기지 않으면, 뷰는 더 이상 존재하지 않는 페이지를 그리는 대신 옛 페이지 핸들을 언로드합니다
PDFium Component TPdfView 지연 XFA 리프레시 다이어그램: 네이티브 레이아웃 호출 스택 안의 페이지 이벤트는 대기 리프레시만 표시하고 윈도우 메시지를 보내며, 그 메시지가 나중에 낡은 선택 상태를 지우고 페이지를 새 PageCount로 클램프한 뒤 페이지 핸들을 다시 로드하거나 언로드함
재로드는 네이티브 호출 스택이 풀릴 때까지 기다립니다. 보낸 메시지가 반복 이벤트를 합치고, 그다음 뷰가 페이지를 클램프하고 다시 로드한 뒤 OnPageChange를 일으킵니다

같은 제약이 여러분의 코드에도 적용됩니다. OnXfaPageCountChanged는 그 네이티브 레이아웃 콜백 안에서 돌므로 알림처럼 다루세요. 라벨, 스피너 범위, 툴바 상태는 거기서 갱신하고, 문서를 닫거나 다른 문서를 여는 것 같은 더 무거운 일은 보낸 메시지로 큐에 넣어 콜백이 돌아온 뒤에 실행되게 하세요. TPdfView.OnPageChange는 뷰가 실제로 페이지를 다시 로드했을 때 알려 주며, 그 시점에 PdfView1.PageNumber를 읽으면 클램프된 값을 얻습니다. Tab 키 이동과 폼 뷰어가 열 때 도는 FormType 검사는 PDFium Component로 PDF 폼 필드 탐색하기에서 다룹니다

Full XFA 필드를 클릭하면 왜 "Cannot open text page"가 올라올까?

Full XFA 페이지에는 PDF 텍스트 페이지가 없는데, v3.126.2 전의 뷰어의 기본 텍스트 선택과 링크 감지는 어차피 하나를 로드하려 했습니다. TPdfView.AllowUserTextSelection이 기본값 True일 때 호버링은 마우스 아래 문자를 텍스트 레이어에 물었고, 마우스 업 클릭은 페이지 텍스트 위로 자동 URL 탐지를 돌렸습니다. Full XFA 페이지에서는 텍스트 페이지를 열 수 없으므로, 필드로의 평범한 클릭이 Cannot open text page 예외로 끝날 수 있었습니다. v3.126.2부터 두 내부 경로는 TPdf.FormType이 ftXfaFull이고 XFA 런타임이 사용 가능할 때 결과 없음을 돌려주므로, 기본 설정이 동작하고 필드 입력은 사용 가능하게 남습니다

Full XFA 문서에서 AllowUserTextSelection을 끄는 것은 여전히 합리적인 UI 선택입니다. 선택할 페이지 텍스트가 없고 드래그 제스처가 선택 모드를 시작해서도 안 되니까요. 하지만 업그레이드의 대용은 아닙니다. 이전 버전에서 클릭 때의 URL 탐지는 그 속성에 의존하지 않았으므로, 선택이 꺼져 있어도 같은 예외를 맞을 수 있었습니다

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType은 열린 문서를 읽으므로 FPdf.Active := True 뒤에 호출
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // Full XFA 페이지에는 PDF 텍스트 레이어가 없음, 필드는 편집 가능하게 유지
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

입력은 v3.126.2에서 자기 수리가 필요했습니다. 네이티브 XFA 텍스트 편집기는 문자를 받을 때 선택을 대체하지 않습니다. FORM_OnChar는 캐럿에 삽입하고 Backspace는 문자 하나를 지우므로, 값을 선택하고 그 위에 입력하면 옛 텍스트와 새 텍스트가 나란히 놓였습니다. PDFium Component는 이제 클릭이 XFA 텍스트 필드 위에 떨어졌음을 기억하고, 선택이 존재하고 문서가 폼 작성 또는 수정 권한을 부여할 때마다 입력된 문자, Backspace, Delete를 FORM_ReplaceSelection으로 라우팅합니다. 읽기 전용 XFA 필드가 바뀔 수 있는지는 여전히 네이티브 편집기가 정하므로, 폼에서 읽기 전용으로 표시된 필드는 다른 면에서 작성을 허용하는 문서에서도 값을 유지합니다. TPdfView.AllowFormEvents를 False로 설정해도 이 키보드 라우팅이 멈추므로, 읽기 전용 뷰어는 읽기 전용으로 유지됩니다

빠른 참조: Delphi 뷰어의 다이내믹 XFA

증상원인수정 버전
폼이 두 페이지로 자란 뒤 페이지 수가 1로 표시됨네이티브 페이지 이벤트가 총계가 아니라 추가/삭제 델타를 넘김v3.126.1(래퍼)
필드 테두리는 움직이고 입력 텍스트와 히트 영역은 뒤처짐자기 비교 뒤 로드된 위젯이 재배치를 건너뜀v3.126.1(Windows V8 라이브러리)
뷰어가 레이아웃 이전 페이지 상태로 그리거나 입력을 라우팅함재페이지매김 뒤 페이지 핸들이 다시 로드되지 않음v3.126.2(지연 리프레시)
필드 클릭이 Cannot open text page를 일으킴텍스트 레이어 없는 페이지에서의 텍스트 선택과 URL 탐지v3.126.2
선택한 값 위에 입력하면 대체 대신 덧붙임네이티브 XFA 편집기가 캐럿에 삽입v3.126.2
  • 어떤 문서가 로드되기 전에 EnableV8Engine을 True로 설정하고, 평범한 라이브러리가 먼저 로드된 경우를 위해 OnXfaRuntimeMissing을 처리할 것
  • 총계는 TPdf.PageCount나 OnXfaPageCountChanged의 NewCount 매개변수에서 읽을 것. 페이지 수를 직접 더하거나 빼지 말 것
  • OnXfaPageCountChanged 핸들러는 가볍게 유지할 것. 네이티브 레이아웃 콜백 안에서 돕니다
  • 현재 페이지 표시기는 TPdfView.OnPageChange에서 동기화할 것. 지연된 재로드가 페이지 번호를 클램프한 뒤 발화합니다
  • v3.126.1 이상의 Windows V8 DLL을 유닛과 함께 배포할 것. 위젯 재배치 수정은 네이티브 코드에 삽니다
  • 페이지 수를 실제로 바꾸고 편집된 필드를 페이지 경계를 넘어 이동시키는 폼으로 테스트할 것. 고정 길이 샘플은 이 목록의 모든 버그를 숨깁니다

다이내믹 XFA는 페이지 수와 필드 기하를 살아 있는 값으로 만듭니다. 뷰어는 그것들을 완료된 레이아웃에서 가져오고 안전한 순간에 페이지를 다시 로드할 때만 정확하게 유지됩니다. PDFium Component는 그 둘을 TPdf와 TPdfView 안에서 처리하므로, 호스트는 듣기만 하면 됩니다. 세부와 다운로드는 Delphi용 PDFium Component 제품 페이지에 있습니다