Artykuł techniczny

FPDF_FORMFILLINFO w wersji 2 w Delphi: trzymaj się ABI DLL

PDFium Component ustawia teraz FPDF_FORMFILLINFO.version na 2 dla każdego środowiska wypełniania formularzy, które inicjalizuje, bo wersja akceptowana przez natywną kompilację PDFium jest właściwością tej kompilacji, a nie otwieranego dokumentu. pdfium.v8.dll z obsługą XFA odrzuca wersję 1 wprost, więc zwykły PDF z AcroForm otwierany przez nią padał w FPDFDOC_InitFormFillEnvironment, choć nigdzie w pobliżu nie było XFA. Poprawka w v3.116.0 jest mała, ale błąd pod nią jest ogólny i wart nazwania: pole wersji protokołu opisuje układ pamięci, którego oczekuje druga strona, i nigdy nie wolno go wyprowadzać z tego, czy akurat potrzebujesz funkcji, które ten układ niesie

Dlaczego FPDFDOC_InitFormFillEnvironment zawodzi na zwykłym PDF-ie z pdfium.v8.dll?

Środowisko zawodzi, bo kompilacja PDFium z obsługą XFA waliduje pole version, zanim zrobi cokolwiek innego, a stara logika wrappera podawała jej 1, gdy bieżący dokument nie był formularzem XFA. Objawem w hoście Delphi jest EPdfError zgłaszany z TPdf.InitializeFormFill z komunikatem Cannot initialize form fill environment, rzucany przy otwieraniu zwykłej faktury czy formularza podatkowego, który ma tylko pola tekstowe AcroForm. Ten sam plik otwiera się bez problemu na zwykłym pdfium.dll. Ta sama biblioteka otwiera prawdziwy dokument XFA bez problemu. Psuje się wyłącznie kombinacja kompilacji V8 i dokumentu bez XFA — a to dokładnie ta kombinacja, w której host ląduje po włączeniu EnableV8Engine dla JavaScriptu AcroForm albo po tym, jak automatyczny wybór w LoadDocument związał już proces z pdfium.v8.dll przy wcześniejszym pliku XFA. To zobowiązanie jest procesowe: EnableV8Engine jest czytane przed pierwszym LoadLibrary, a gdy kompilacja XFA jest już wczytana, każdy późniejszy zwykły PDF przechodzi przez to samo ustawianie środowiska na tej samej bibliotece. Host nie zrobił nic źle; wrapper zadał niewłaściwe pytanie, wypełniając rekord. Jeśli wciąż wybierasz, którą bibliotekę w ogóle wysyłać, nasza notatka o wdrażaniu biblioteki PDFium i diagnozowaniu błędów wczytywania omawia wybór między zwykłą wersją a V8, a ten artykuł zakłada, że kompilacja V8 jest już w procesie

Diagram PDFium Component z czterema kombinacjami zwykłego pdfium.dll oraz pdfium.v8.dll z XFA wobec dokumentów AcroForm i XFA: rekord w wersji 1 psuł wyłącznie kompilację V8 ze zwykłym formularzem, dając EPdfError w FPDFDOC_InitFormFillEnvironment, natomiast poprawiony rekord w wersji 2 otwiera wszystkie cztery
Jeden warunek wiązał wersję ABI z dokumentem, więc procesowy wybór biblioteki V8 zamieniał każdy późniejszy zwykły PDF w nieudaną inicjalizację środowiska

Co właściwie obiecuje pole version w FPDF_FORMFILLINFO?

FPDF_FORMFILLINFO.version mówi PDFium, które pola rekordu wolno mu czytać, a publiczny nagłówek fpdf_formfill.h wiąże dopuszczalne wartości ze sposobem skompilowania biblioteki, a nie z dokumentem. W parafrazie kontrakt ma trzy części. Wersja 1 obejmuje stabilne callbacki od FFI_Invalidate do FFI_DoGoToAction plus wskaźnik m_pJsPlatform. Kompilacja bez modułu XFA przyjmuje 1 albo 2, a przy 2 będzie też wołać dodatkowe callbacki eksperymentalne. Kompilacja z modułem XFA wymaga 2, kropka, i nagłówek powtarza ten wymóg dwa razy, jakby spodziewał się, że ludzie go przegapią. Nigdzie ten kontrakt nie wspomina dokumentu. Wersja to oświadczenie o rekordzie, który zaalokowałeś: mówiąc 2, obiecujesz, że pamięć za m_pJsPlatform istnieje i trzyma albo poprawne wskaźniki funkcji, albo NULL

Właśnie w obszarze wersji 2 mieszka cała maszyneria XFA. Zaczyna się od xfa_disabled, czyli FPDF_BOOL, który nagłówek opisuje jako ignorowany poniżej wersji 2 i znaczący tylko wtedy, gdy moduł XFA jest wkompilowany, a ciągnie się dalej przez siedemnaście wskaźników funkcji, od FFI_DisplayCaret do FFI_DoURIActionWithKeyboardModifier. Każdy z nich jest udokumentowany jako wymagany dla XFA, a poza tym przeznaczony do ustawienia na NULL. To sformułowanie jest kluczem do całej poprawki. NULL nie jest dla tych slotów stanem błędu; to udokumentowany stan dla hosta, który nie napędza XFA. Rekord wyczyszczony przez FillChar i oznaczony jako wersja 2 spełnia kontrakt na kompilacji bez XFA równie dobrze jak rekord w wersji 1, i jest jedynym rekordem, jaki kompilacja z XFA przyjmie

Diagram PDFium Component z rekordem FPDF_FORMFILLINFO w Delphi: wersja 1 obejmuje callbacki od FFI_Invalidate do FFI_DoGoToAction plus m_pJsPlatform, wersja 2 dodaje xfa_disabled i siedemnaście wskaźników z ery FFI_DisplayCaret, FillChar zeruje każdy bajt, a sloty NULL to udokumentowany stan dla hosta, który nie napędza XFA
Rekord Pascala zawsze ma pełny układ wersji 2, więc kompilacja z XFA go przyjmuje, a zwykła kompilacja po prostu nigdy nie woła eksperymentalnych slotów, które zostają NULL

Stary wybór wiązał ABI z dokumentem

Defektem był jeden warunek, który w izolacji wyglądał rozsądnie. TPdf.InitializeFormFill liczy flagę RuntimeReady z trzech faktów: dokument zgłasza typ formularza XFA przez TPdf.XFA, helpery łańcuchów XFA rozwiązały się przez XfaFeaturesAvailable, a eksporty V8 rozwiązały się przez V8FeaturesAvailable. Przed v3.116.0 ta sama flaga wybierała też wersję

// v3.115.0 i wcześniejsze: wersja ABI szła za dokumentem
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;

if RuntimeReady then
  FFormFillInfo.Info.version := 2
else
  FFormFillInfo.Info.version := 1;

// ... a gałąź braku runtime przypinała ją ponownie
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

Przeczytaj to z nagłówkiem w ręku i awaria jest oczywista. RuntimeReady jest fałszywe dla każdego zwykłego dokumentu AcroForm, więc każdy zwykły dokument ogłaszał wersję 1. Na pdfium.dll to nie szkodzi. Na pdfium.v8.dll, czyli kompilacji z XFA, PDFium sprawdza pole, widzi wartość poniżej wymaganej 2 i zwraca pusty FPDF_FORMHANDLE, który CheckPdf zamienia w wyjątek powyżej. Intencja starego kodu była defensywna: trzymać wersję 1, żeby kompilacja XFA nigdy nie czytała nieprzypisanych slotów wersji 2. Broniła się przed problemem, który nagłówek już wyklucza, i stworzyła taki, przed którym nagłówek wyraźnie ostrzega. Poprawiony kod rozstrzyga wersję raz, z góry, z tego, czym rekord fizycznie jest

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // wartownik: użyj statycznego drzewa stron
  if not FormFill then
    Exit;

  FillChar(FFormFillInfo, SizeOf(FFormFillInfo), 0);
  FFormFillInfo.Pdf := Self;

  // Pełny rekord wersji 2 jest alokowany i czyszczony powyżej. PDFium
  // przyjmuje wersję 2 bez XFA i wymaga jej w każdej kompilacji z XFA,
  // także gdy ten dokument nie zawiera formularza XFA.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady bramkuje callbacki XFA i xfa_disabled, nigdy wersję.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

Gdzie RuntimeReady nadal należy: callbacki i xfa_disabled

RuntimeReady zachowuje swoją rolę bramki dla zachowania XFA; po prostu nie dotyka już układu rekordu. Callbacki wersji 1 — FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction i reszta tego bloku — są podłączane bezwarunkowo, bo i AcroForm, i XFA na nich polegają. Siedemnaście wskaźników wersji 2 jest przypisywanych tylko w gałęzi RuntimeReady, razem z xfa_disabled := 0. Gdy dokument jest XFA, ale środowiska uruchomieniowego nie ma, rekord zostaje w wersji 2 z xfa_disabled równym 1 i slotami wersji 2 pozostawionymi jako NULL, a wrapper zgłasza OnXfaRuntimeMissing, żeby host mógł zasugerować restart na pdfium.v8.dll. Po powstaniu środowiska FPDF_LoadXFA jest wołane tylko wtedy, gdy RuntimeReady było prawdziwe, a tylko prawdziwy wynik ustawia FXfaRuntimeUsable, co raportuje TPdf.XfaRuntimeAvailable

  if RuntimeReady then
  begin
    FFormFillInfo.Info.xfa_disabled := 0;   // 0 = XFA enabled
    FFormFillInfo.Info.FFI_DisplayCaret := FormFillDisplayCaret;
    FFormFillInfo.Info.FFI_GetCurrentPageIndex := FormFillGetCurrentPageIndex;
    FFormFillInfo.Info.FFI_SetCurrentPage := FormFillSetCurrentPage;
    FFormFillInfo.Info.FFI_GotoURL := FormFillGotoURL;
    FFormFillInfo.Info.FFI_GetPageViewRect := FormFillGetPageViewRect;
    FFormFillInfo.Info.FFI_PageEvent := FormFillPageEvent;
    FFormFillInfo.Info.FFI_PopupMenu := FormFillPopupMenu;
    FFormFillInfo.Info.FFI_OpenFile := FormFillOpenFile;
    FFormFillInfo.Info.FFI_EmailTo := FormFillEmailTo;
    // ... od FFI_UploadTo do FFI_DoURIActionWithKeyboardModifier
  end
  else if XFA then
  begin
    // Runtime niedostępny: zachowaj wersję 2, zostaw XFA wyłączone, powiadom hosta.
    if Assigned(FOnXfaRuntimeMissing) then
      FOnXfaRuntimeMissing(Self);
  end;

  FFormHandle := FPDFDOC_InitFormFillEnvironment(FDocument, FFormFillInfo.Info);
  CheckPdf(FFormHandle <> nil, 'Cannot initialize form fill environment');
  if RuntimeReady then
    FXfaRuntimeUsable := FPDF_LoadXFA(FDocument) <> 0;

Dwa szczegóły w tym bloku łatwo zapisać źle, pisząc własne wiązanie. FXfaPageCountOverride jest resetowane do -1 jako wartownik, zanim cokolwiek innego się stanie, więc PageCount spada do statycznego drzewa stron, dopóki FFI_PageEvent nie zgłosi repaginacji; zero w tym miejscu po cichu twierdziłoby, że dokument jest pusty. A każdy z callbacków wersji 2 to statyczna procedura cdecl, która odzyskuje właściciela TPdf z rekordu i połyka każdy wyjątek Pascala przed powrotem do PDFium — to dyscyplina, którą nasza notatka o utwardzaniu ABI PDFium w Delphi wykłada dla FFI_OpenFile. Zmiana wersji nie łagodzi żadnej z tych dwóch reguł

Czy wersja 2 jest bezpieczna, gdy biblioteka nie ma modułu XFA?

Tak, a powód tkwi w rekordzie, nie w obietnicy biblioteki. Przy kompilacji bez XFA nagłówek mówi, że wersja 2 powoduje też wołanie callbacków eksperymentalnych, więc pytanie brzmi, co PDFium znajdzie, gdy tam zajrzy. TPdfFormFillInfo to rekord pakowany, którego składowa Info jest pełnym FPDF_FORMFILLINFO wraz z każdym polem wersji 2, a InitializeFormFill czyści całość przez FillChar, zanim dotknie choćby bajtu. Zatem na zwykłym pdfium.dll ze zwykłym dokumentem biblioteka widzi wersję 2, ustawione xfa_disabled i NULL w każdym slocie eksperymentalnym, co jest dokładnie tym stanem, który nagłówek przepisuje dla hosta nieimplementującego XFA. Nie ma żadnego obciętego rekordu, po którym biblioteka mogłaby czytać dalej, bo rekord nigdy nie był krótszy niż wersja 2. Stara logika broniła się przed niezgodnością układu, którą deklaracja Pascala już wyeliminowała

Granica, którą warto uczciwie wskazać, to ta, której rekord nie obejmuje. Wersja 2 przy zwykłym dokumencie nie włącza JavaScriptu, skryptów XFA ani żadnego ze zdarzeń hosta stojących za tymi callbackami. m_pJsPlatform jest podłączane tylko wtedy, gdy V8FeaturesAvailable jest prawdziwe, XFA zostaje wyłączone, o ile RuntimeReady nie było prawdziwe, a TPdf.XFA nadal raportuje typ formularza z FPDF_GetFormType, niezależnie od tego, co wynegocjowało środowisko. Host, który chce wiedzieć, czy dynamiczne XFA naprawdę się wyrenderuje, powinien dalej czytać XfaRuntimeAvailable po tym, jak Active stanie się prawdziwe — tak jak zaleca nasza notatka o wykrywaniu formularzy XFA i wyciąganiu pakietów XFA — zamiast wnioskować cokolwiek z pola wersji

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Odpala się z InitializeFormFill, gdy dokument jest XFA, ale wczytana
  // pdfium.dll nie potrafi uruchomić silnika. Środowisko formularza i tak się otwiera,
  // bo wersja 2 została przekazana tak czy tak; wyłączony jest tylko runtime XFA.
  StatusBar.SimpleText :=
    'XFA form detected; restart with pdfium.v8.dll to enable 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;   // nie rzuca już wyjątku przy zwykłym PDF-ie na pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

Wersja protokołu i dostępność funkcji to dwie różne osie

Ogólna reguła, która z tej poprawki wynika, jest taka, że pole wersji w strukturze callbacków odpowiada na pytanie „jak duży jest ten rekord i co wolno ci z niego czytać”, a wykrywanie funkcji odpowiada na pytanie „które z tych slotów zrobią coś pożytecznego”. Pierwsze jest ustalone przez natywną bibliotekę i przez deklarację Pascala, pod którą się skompilowałeś. Drugie zmienia się z dokumentem, z tablicą eksportów biblioteki i z konfiguracją hosta. Zwijanie obu w jeden boolean kusi, bo przypadek XFA akurat potrzebuje i jednego, i drugiego, ale w chwili, gdy jakaś kompilacja wymusza minimalną wersję, to zwinięcie psuje się dla każdego dokumentu, który tej funkcji nie potrzebuje. Formularze XFA, opisane w ISO 32000-1 §12.7.8 jako ładunek XML żyjący obok słownika AcroForm, są tu tą funkcją; układ rekordu jest protokołem i PDFium ma prawo nalegać na ten układ, zanim w ogóle spojrzy na plik. Ten sam kształt pojawia się wszędzie, gdzie biblioteka w C wersjonuje swoje struktury: blok informacji o przeglądarce, rekord opcji renderowania, tablica callbacków platformy. Bezpieczny wzorzec to ten, który stosuje poprawione InitializeFormFill. Zadeklaruj najnowszy układ, jaki rozumiesz, wyczyść go w całości, ustaw wersję zgodną z tym układem bezwarunkowo, a potem pozwól sprawdzeniom możliwości rozstrzygnąć, które sloty wypełnić. Jeśli przyszły nagłówek PDFium doda wersję 3, zmiana dotyczy deklaracji i tego jednego przypisania, a nie zależnej od dokumentu gałęzi, która będzie błędna dla tej kombinacji, której nikt nie przetestował

Diagram PDFium Component rozdzielający dwie osie stojące za FPDF_FORMFILLINFO: wersję protokołu ustaloną przez układ rekordu i natywną bibliotekę oraz dostępność funkcji, gdzie RuntimeReady bramkuje xfa_disabled, siedemnaście slotów wersji 2, FPDF_LoadXFA i m_pJsPlatform zależnie od dokumentu i hosta
Pole wersji opisuje pamięć, którą druga strona może czytać, sprawdzenia możliwości rozstrzygają, które sloty robią coś pożytecznego, a zwinięcie obu w jeden boolean psuje kompilację wymuszającą minimum

Poprawiona inicjalizacja wypełniania formularzy jest w PDFium Component dla Delphi, Lazarusa i C++Buildera i działa tak samo na Win32, jak i Win64, bo obie kompilacje dzielą tę samą deklarację rekordu. Jeśli twoja aplikacja już wybiera pdfium.v8.dll dla AcroFormów napędzanych JavaScriptem, to jest ta zmiana, która pozwala jej otwierać resztę archiwum PDF przez tę samą bibliotekę bez specjalnego traktowania środowiska formularzy