텍스트 필드 몇 개뿐이고 XFA 콘텐츠가 전혀 없는 AcroForm 인보이스는 평범한 pdfium.dll에서는 잘 열리는데, 같은 파일이 XFA가 켜진 pdfium.v8.dll 위에서 실행 중인 프로세스에서는 FPDFDOC_InitFormFillEnvironment가 NULL을 돌려주게 만들 수 있습니다. 두 실행에서 문서의 바이트는 동일합니다. 다른 것은 정수 하나입니다. PDFium의 XFA 빌드는 FPDF_FORMFILLINFO.version이 2여야 하는데, XFA 없는 문서에 1을 쓰는 바인딩은 PDFium이 콜백을 하나도 들여다보기 전에 거절당합니다
FPDFDOC_InitFormFillEnvironment는 평범한 AcroForm PDF를 왜 거부할까?
거부는 바이너리에서 나오며 문서는 발언권이 없습니다. form-fill 헤더, 즉 PDFium의 fpdf_formfill.h와 그 Pascal 번역인 FPdfFormFill.pas는 XFA 모듈로 컴파일된 빌드가 버전 2를 요구한다고 밝히고, 같은 주석 블록 안에서 두 번 말합니다. XFA 없는 빌드는 1이나 2를 모두 받습니다. XFA가 아닌 파일에 버전 1을 보내는 바인딩에서는 바이너리와 문서의 네 조합 가운데 단 하나만 실패합니다. 버전 1 레코드가 pdfium.v8.dll에 평범한 AcroForm 파일과 함께 넘겨지는 경우입니다. 평범한 DLL은 그 레코드를 받고, V8 DLL은 어떤 문서든 버전 2 레코드를 받습니다. V8 DLL의 실제 XFA 폼은 문서에 따라 버전을 정하는 바인딩에서 버전 1을 받는 일이 없으므로, 실패하는 칸은 정확히 아무도 테스트할 생각을 하지 않는 그 칸입니다
직접 작성한 바인딩은 그 지점에서 FPDFDOC_InitFormFillEnvironment가 NULL을 돌려주는 것만 보고 더 갈 곳이 없습니다. PDFium Component의 TPdf는 거부된 환경을 Active := True 중 EPdfError로 보고하겠지만, 항상 버전 2를 쓰므로 이 칸은 거기서 등장하지 않습니다. 그 칸에 들어가는 길은 쉽습니다. 호스트가 AcroForm JavaScript를 원해서 전역 EnableV8Engine을 True로 정하면, 그 순간부터 프로세스의 모든 PDF가 V8 바이너리를 거칩니다. 아니면 호스트가 플래그를 건드리지 않았는데 첫 파일의 AcroForm 딕셔너리에 /XFA 항목이 있거나 카탈로그에 /NeedsRendering true가 있는 경우입니다. PDFium 라이브러리가 아직 로드 전일 때 TPdf는 파일에서 이 두 표식을 찾아 DLL을 로드하기 전에 EnableV8Engine을 켭니다. 어느 쪽이든 선택은 프로세스의 나머지 동안 유지되고, 그 뒤에 열리는 모든 평범한 AcroForm 파일이 XFA 빌드를 만납니다. 처음부터 어떤 바이너리를 실어 보낼지는 PDFium DLL 배포와 로드 실패 진단 가이드에서 다루고, 아래 내용은 V8 빌드가 이미 로드됐다고 가정합니다
FPDF_FORMFILLINFO의 버전 필드가 약속하는 것
FPDF_FORMFILLINFO.version은 PDFium이 레코드의 어디까지 읽어도 되는지를 알려 줍니다. 버전 1은 안정 부분, 즉 Release, FFI_Invalidate부터 FFI_DoGoToAction까지의 콜백들, m_pJsPlatform 포인터를 커버합니다. 버전 2는 실험적 꼬리를 더합니다. XFA 없는 빌드가 버전 2를 받으면 그 자리를 찾아 실험적 콜백들을 호출하기도 하고, XFA 빌드는 이보다 적은 것을 받지 않습니다. 헤더는 문서에 대해 전혀 언급하지 않습니다. 2를 쓴다는 것은 m_pJsPlatform 뒤의 메모리가 존재하고 그 안의 모든 슬롯이 유효한 cdecl 함수거나 NULL임을 약속하는 것입니다
그 꼬리는 버전 2 미만에서 무시되는 FPDF_BOOL인 xfa_disabled로 시작해, FFI_DisplayCaret부터 FFI_DoURIActionWithKeyboardModifier까지 열일곱 개의 함수 포인터가 뒤따릅니다. 이 가운데 열다섯, FFI_DisplayCaret부터 FFI_PutRequestURL까지는 "Required for XFA, otherwise set to NULL"로 문서화돼 있습니다. 마지막 둘인 FFI_OnFocusChange와 FFI_DoURIActionWithKeyboardModifier는 모든 호스트에게 선택적이며, 헤더는 버전 2 임베더가 FFI_DoURIActionWithKeyboardModifier를 비워 두면 URI 클릭이 FFI_DoURIAction으로 우회된다고 덧붙입니다. 종합하면, XFA를 구동하지 않는 호스트에게 NULL이 규격이 정한 값입니다. FillChar로 0을 채우고 버전 2를 찍은 레코드는 평범한 빌드에서 버전 1 레코드와 정확히 같게 계약을 충족하고, 동시에 XFA 빌드가 받아들이는 유일한 레코드입니다
JavaScript 쪽도 같은 패턴입니다. m_pJsPlatform은 V8 없는 빌드에서 쓰이지 않고, V8 빌드에서는 NULL 포인터가 문서 렌더링은 되지만 JavaScript가 돌지 않게 만듭니다. 그것이 가리키는 IPDF_JsPlatform 레코드에는 자체 version 필드가 있는데, 헤더는 현재 그것이 2여야 한다고 말합니다
함정: 문서가 ABI 버전을 고르게 두기
실수는 그 자체로는 말이 되는 조건문 하나입니다. 직접 작성한 바인딩은 어차피 XFA 콜백을 연결할 가치가 있는지 판단해야 하고, 같은 검사에 버전 번호 선택까지 맡기고 싶어집니다
// A tempting shape for a hand-written binding: the version follows the document
FillChar(FormInfo, SizeOf(FormInfo), 0);
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
if IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable then
FormInfo.version := 2
else
FormInfo.version := 1; // XFA가 아닌 모든 문서는 1을 받음
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// on pdfium.v8.dll with a plain AcroForm file, FormHandle is nil here
그 분기의 의도는 방어적입니다. 버전을 1로 유지해 XFA 빌드가 호스트가 채우지 않은 슬롯을 읽지 않게 하려는 것입니다. 하지만 바로 위 줄의 FillChar가 이미 그 슬롯들을 NULL로 채웠고, 헤더는 그것을 XFA를 구동하지 않는 호스트의 올바른 값으로 정의합니다. 이 분기는 선언이 이미 제거한 위험을 막느라 헤더가 경고하는 위험으로 곧장 걸어 들어갑니다. 그리고 XFA 파일을 테스트해서는 잡을 수 없습니다. 그 파일들은 언제나 버전 2 경로를 타니까요
PDFium Component는 레코드를 반대 방향으로 만듭니다. TPdf.FormFill이 True이면 문서를 여는 것이 전체 레코드를 지우고, 버전 2와 xfa_disabled := 1을 쓴 뒤에야 문서가 무엇을 필요로 하는지 묻습니다. 어떤 바인딩에든 적용되는 규칙으로 말하면, 선언한 레코드에 맞춰 버전을 정하고 다른 무엇도 그것을 건드리게 하지 마세요. 아래는 여러분 코드를 위한 그 모양이며, THostFormFillInfo 래퍼는 뒤에서 선언합니다
// FormInfo is a THostFormFillInfo field of your viewer object, so it outlives
// 오래 삽니다. HostInvalidate, HostGetPage 등은
// 여러분의 cdecl 콜백입니다
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;
FormInfo.Info.version := 2; // 선언된 레이아웃과 항상 일치
FormInfo.Info.xfa_disabled := 1; // 쓸 수 있음이 증명될 때까지 XFA는 끔
FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... 나머지 버전 1 콜백들
IsXfa := FPDF_GetFormType(Doc) in [FORMTYPE_XFA_FULL, FORMTYPE_XFA_FOREGROUND];
RuntimeReady := IsXfa and XfaFeaturesAvailable and V8FeaturesAvailable;
if RuntimeReady then
begin
FormInfo.Info.xfa_disabled := 0;
FormInfo.Info.FFI_DisplayCaret := HostDisplayCaret;
FormInfo.Info.FFI_PageEvent := HostPageEvent;
// ... 나머지 버전 2 슬롯들
end;
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo.Info);
if FormHandle = nil then
raise Exception.Create('Cannot initialize form fill environment');
XfaRunning := RuntimeReady and (FPDF_LoadXFA(Doc) <> 0);
RuntimeReady와 V8FeaturesAvailable의 자리
능력 검사는 어떤 슬롯이 채워지는지를 정할 뿐, PDFium Component에서는 레이아웃을 절대 건드리지 않습니다. TPdf는 세 사실로 준비 상태를 계산합니다. 문서가 TPdf.XFA로 XFA 폼 타입을 보고하는 것, 전역 XfaFeaturesAvailable이 로드된 DLL이 내보내는 XFA 문자열 헬퍼를 찾는 것, 그리고 V8FeaturesAvailable이 V8 익스포트를 찾는 것입니다. 버전 1 콜백은 AcroForm과 XFA가 모두 의존하므로 모든 문서에 대해 연결됩니다. 열일곱 개의 버전 2 슬롯이 모두 할당되고 xfa_disabled가 지워지는 것은 세 사실이 모두 성립할 때뿐입니다. 환경이 생긴 뒤 FPDF_LoadXFA는 그 경우에만 돌고, 0이 아닌 반환만이 TPdf.XfaRuntimeAvailable을 True로 만듭니다. FPDF_LoadXFA는 XFA 엔진 없는 빌드에서도 익스포트되어 거기서 그냥 false를 돌려주므로, 익스포트 검사만으로는 부족한 이유가 이것입니다
문서는 XFA인데 런타임이 없으면 레코드는 버전 2에 xfa_disabled 1, 꼬리는 NULL로 머뭅니다. 환경은 열리고 XFA 콘텐츠는 정적으로 남으며, TPdf가 OnXfaRuntimeMissing을 발생시켜 호스트가 pdfium.v8.dll로의 재시작을 안내할 수 있게 합니다. m_pJsPlatform은 세 번째 규칙을 따릅니다. V8FeaturesAvailable이 참이면 평범한 AcroForm 파일에도 붙는데, 덕분에 V8 빌드 아래에서 문서 수준 스크립트와 필드 계산, app.alert가 동작합니다. PageCount는 XFA가 FFI_PageEvent로 문서를 재구성하기 전까지 정적 페이지 트리를 읽고, 그 시점부터 새 총수를 FPDF_GetPageCount에서 가져와 OnXfaPageCountChanged로 보고합니다
호스트 쪽에서 두 속성은 서로 다른 질문에 답합니다. XFA는 바이너리와 무관하게 FPDF_GetFormType에서, 파일이 무엇을 담고 있는지를 알려 줍니다. XfaRuntimeAvailable은 이 문서에 대해 엔진이 실제로 시작했는지를 알려 줍니다. XFA 폼 탐지와 XFA 패킷 추출 글이 그러하듯 두 번째 것은 Active가 True가 된 뒤에 읽고, 버전 필드는 판단 근거에서 완전히 빼세요
procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
// 문서가 XFA인데 로드된 DLL이 엔진을 실행할 수 없을 때 열기 도중
// 발생합니다. form 환경은 존재하고 XFA만 꺼져 있습니다
StatusBar.SimpleText :=
'XFA form detected; restart with pdfium.v8.dll for dynamic rendering';
end;
procedure TMainForm.OpenDocument(const FileName: string);
begin
Pdf.Active := False;
Pdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
Pdf.FormFill := True;
Pdf.FileName := FileName;
Pdf.Active := True; // 평범한 AcroForm 파일도 pdfium.v8.dll에서 열림
if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
ShowStaticXfaNotice; // 여러분의 UI 루틴
end;
버전을 읽기 전에 레코드를 깨는 레이아웃 실수들
올바른 버전 번호는 그 뒤의 바이트가 C 컴파일러가 놓은 자리에 있을 때만 도움이 됩니다. 몇 가지 바인딩 오류는 어떤 버전을 실었든 PDFium이 잘못 읽는 레코드를 만들고, 그중 하나는 Win32에서는 보이지 않습니다. FPDF_FORMFILLINFO를 packed record로 선언하는 것입니다. Win32에서는 모든 필드가 4바이트라 packed와 정렬된 레이아웃이 우연히 일치합니다. Win64에서는 C 구조체가 version 뒤에 4바이트를 패딩해 Release가 8바이트 경계에 놓이게 하고 xfa_disabled 뒤에서도 한 번 더 패딩하는데, packed Pascal 선언은 두 갭을 모두 없애 모든 콜백 포인터를 4바이트씩 밀어 버립니다. FPdfFormFill.pas의 선언이 그런 이유로 평범한 record입니다
두 번째 오류는 잘린 레코드입니다. m_pJsPlatform에서 선언을 끝내면서 버전 2를 쓰는 번역이 그것입니다. PDFium은 그러면 레코드 뒤를 잇는 아무 메모리에서나 꼬리를 읽습니다. 세 번째는 수명입니다. 헤더는 FPDFDOC_InitFormFillEnvironment에 넘긴 레코드가 돌려준 form 핸들이 닫힐 때까지 유효해야 한다고 말합니다. 문서를 여는 프로시저의 지역 변수에 레코드를 들고 있으면, 그 프로시저가 반환하는 순간 그것은 댕글링 포인터입니다. TPdf는 자기 레코드를 컴포넌트의 필드로 유지하며, PDFium 구조체를 오프셋 0에 두고 바로 뒤에 소유 TPdf로의 역방향 포인터를 놓는 바깥 레코드로 감쌉니다. 모든 정적 콜백이 자기 객체를 다시 찾는 방식입니다
type
// Win64에서 틀림: packing이 C 구조체가 version 뒤에 두는 패딩을
// 없애므로 Release가 여기서는 오프셋 4, PDFium에서는 오프셋 8에 놓임
TBadFormFillInfo = packed record
version: Integer;
Release: Pointer;
// ... 나머지 필드들
end;
// 여러분의 래퍼: PDFium 레코드가 먼저, 그 뒤에 역방향 포인터
THostFormFillInfo = record
Info: FPDF_FORMFILLINFO; // 오프셋 0이므로 pThis를 곧바로 캐스팅
Owner: TObject;
end;
PHostFormFillInfo = ^THostFormFillInfo;
procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
left, top, right, bottom: Double); cdecl;
begin
try
// TMyViewer와 InvalidatePdfRect는 여러분의 뷰어 클래스 자리입니다
TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
page, left, top, right, bottom);
except
// Pascal 예외가 PDFium의 C++ 프레임을 타고 풀리면 안 됨
end;
end;
두 슬롯이 더 주의를 요합니다. FFI_GetLocalTime은 16바이트 FPDF_SYSTEMTIME을 값으로 돌려주는데, 숨은 결과 포인터가 가는 곳에 대해 Delphi와 MSVC가 Win32에서 의견이 갈려 Pascal 구현은 호출자의 FPDF_FORMFILLINFO를 망가뜨립니다. 현재 PDFium은 이 멤버를 unused로 표시하고 Pascal 선언은 nil로 두라는 주석을 달며, PDFium Component도 그렇게 합니다. 컴포넌트가 설치하는 모든 콜백은 Pascal 예외를 잡아 문서화된 실패 값을 대신 돌려줍니다. C ABI에는 예외를 경계 너머로 전달할 방법이 없기 때문입니다. Delphi에서 PDFium ABI를 굳건히 다지는 글이 그 규율을 FFI_OpenFile 예제로 짚어 줍니다
프로토콜 버전과 기능 가용성은 두 개의 축입니다
콜백 구조체의 버전 필드는 레코드가 얼마나 큰지, 어디까지 읽을 수 있는지에 답합니다. 기능 탐지는 그 슬롯들 가운데 무엇이 실제로 동작할지에 답합니다. 첫째는 네이티브 바이너리와 컴파일 기준의 선언이 고정하지만, 둘째는 문서마다, DLL 익스포트 테이블마다, 호스트 설정마다 달라집니다. XFA 사례는 두 답을 동시에 필요로 하므로 하나의 boolean으로 접고 싶어지고, 그 접기는 빌드가 최소 버전을 강제하는 순간 깨집니다. ISO 32000-1 §12.7.8이 AcroForm 딕셔너리 옆에 실린 XML 페이로드로 기술하는 XFA 폼이 이 이야기의 기능입니다. 레코드 레이아웃이 프로토콜이고, PDFium은 파일을 읽기 전에 레이아웃을 검사할 자격이 있습니다
같은 구조는 C 라이브러리가 구조체에 버전을 다는 어디에서든 나타납니다. FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, 크기나 버전 헤더를 단 콜백 테이블이 그렇습니다. 이해하는 가장 새로운 레이아웃을 선언하고, 완전히 지우고, 대응하는 버전을 무조건 쓰고, 문서와 DLL이 실제로 할 수 있는 일에 따라 슬롯을 채우세요. 미래의 fpdf_formfill.h가 버전 3을 도입하면 해야 할 일은 새 선언과 대입 한 줄 변경입니다. 문서 내용에 키를 둔 분기는 빌드와 파일의 모든 조합마다 다시 검토해야 하고, 그것이 틀리는 조합은 대개 아무도 열어 보지 않은 것입니다
여기서 다룬 레코드 처리는 Delphi, Lazarus, C++Builder용 PDFium Component의 일부이며, 같은 선언이 Win32와 Win64 빌드를 함께 서빙합니다. JavaScript 기반 AcroForm을 위해 이미 pdfium.v8.dll을 실어 나르는 애플리케이션은 form 환경을 위한 별도 처리 없이 그 하나의 바이너리로 나머지 아카이브도 XFA든 아니든 열 수 있습니다