Artykuł techniczny

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

Faktura AcroForm z garścią pól tekstowych i bez treści XFA otwiera się bez problemu przez zwykły pdfium.dll, a ten sam plik potrafi sprawić, że FPDFDOC_InitFormFillEnvironment zwróci NULL, gdy proces działa na włączonej XFA pdfium.v8.dll. Bajty dokumentu są w obu przebiegach identyczne. Różni się jedna liczba całkowita: buildy PDFium z XFA wymagają, by FPDF_FORMFILLINFO.version wynosiło 2, i wiązanie, które wpisuje 1 dla dokumentów bez XFA, zostaje odprawione, zanim PDFium obejrzy choć jeden callback

Dlaczego FPDFDOC_InitFormFillEnvironment odrzuca zwykły PDF AcroForm?

Odrzucenie pochodzi z binarki i dokument nie ma tu nic do gadania. Nagłówek form fill, fpdf_formfill.h w PDFium i jego pascalskie tłumaczenie w FPdfFormFill.pas, stanowi, że build skompilowany z modułem XFA wymaga wersji 2 i mówi to dwa razy w tym samym bloku komentarza. Build bez XFA przyjmie 1 albo 2. Przy wiązaniu wysyłającym wersję 1 dla plików bez XFA tylko jedna z czterech kombinacji binarka-dokument się wywala: rekord wersji 1 podany do pdfium.v8.dll razem ze zwykłym plikiem AcroForm. Zwykła DLL przyjmie taki rekord. DLL V8 przyjmie rekord wersji 2 dla każdego dokumentu. Prawdziwego formularza XFA na DLL V8 wiązanie kluczujące wersję dokumentem nigdy nie wyśle z wersją 1, więc padającą komórką jest dokładnie ta, której nikt nie myśli przetestować

Ręcznie pisane wiązanie widzi w tym momencie NULL z FPDFDOC_InitFormFillEnvironment i nie ma na czym dalej oprzeć diagnozy. TPdf w PDFium Component zgłosiłby odrzucone środowisko jako EPdfError podczas Active := True, ale zawsze wpisuje wersję 2, więc ta komórka się tam nie zdarza. Wpaść w nią jest łatwo. Host ustawia globalne EnableV8Engine na True, bo chce JavaScriptu AcroForm, i od tej chwili każdy PDF w procesie idzie przez binarkę V8. Albo host flagi nie rusza, ale pierwszy otwarty plik ma wpis /XFA w słowniku AcroForm albo /NeedsRendering true w katalogu: gdy biblioteka PDFium nie została jeszcze wczytana, TPdf skanuje plik pod kątem tych dwóch znaczników i włącza EnableV8Engine przed załadowaniem DLL. W obu wypadkach wybór obowiązuje do końca procesu, a każdy zwykły plik AcroForm otwarty później spotyka build z XFA. O tym, którą binarkę w ogóle rozsyłać, pisze przewodnik po wdrażaniu DLL PDFium i diagnozowaniu błędów ładowania; wszystko poniżej zakłada, że build V8 już działa

Diagram PDFium Component z czterema kombinacjami zwykłego pdfium.dll i włączonej XFA pdfium.v8.dll względem dokumentów AcroForm i XFA: rekord wersji 1 zawodzi tylko na buildzie V8 ze zwykłym formularzem, w FPDFDOC_InitFormFillEnvironment, a rekord wersji 2 otwiera wszystkie cztery
przypięcie wersji ABI do dokumentu sprawia, że ogólnoprocesowy wybór binarki V8 zamienia każdy późniejszy zwykły PDF w nieudaną inicjalizację środowiska

Co obiecuje pole wersji w FPDF_FORMFILLINFO

FPDF_FORMFILLINFO.version mówi PDFium, jaką część rekordu może czytać. Wersja 1 obejmuje stabilną część: Release, callbacki od FFI_Invalidate do FFI_DoGoToAction i wskaźnik m_pJsPlatform. Wersja 2 dokłada eksperymentalny ogon. Build bez XFA, który dostanie wersję 2, woła też eksperymentalne callbacki, które tam znajdzie; build z XFA nie przyjmie niczego mniejszego. Nagłówek nie wspomina o dokumencie ani słowem. Wpisując 2, obiecujesz, że pamięć za m_pJsPlatform istnieje i że każdy slot w niej trzyma albo poprawną funkcję cdecl, albo NULL

Ten ogon zaczyna się od xfa_disabled, FPDF_BOOL ignorowanego poniżej wersji 2, po którym idzie siedemnaście wskaźników funkcji od FFI_DisplayCaret do FFI_DoURIActionWithKeyboardModifier. Piętnaście z nich, od FFI_DisplayCaret do FFI_PutRequestURL, jest udokumentowanych jako "Required for XFA, otherwise set to NULL". Ostatnie dwa, FFI_OnFocusChange i FFI_DoURIActionWithKeyboardModifier, są opcjonalne dla każdego hosta, a nagłówek dodaje, że embedder wersji 2, który zostawi FFI_DoURIActionWithKeyboardModifier nieustawione, dostanie swoje kliknięcia URI przekierowane na FFI_DoURIAction. Zebrane razem: NULL jest wartością określoną dla hosta, który nie napędza XFA. Rekord wyzerowany FillChar i opatrzony wersją 2 spełnia kontrakt na zwykłym buildzie dokładnie tak, jak rekord wersji 1, i jest też jedynym rekordem, jaki build 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

Strona JavaScript podąża tym samym wzorcem. m_pJsPlatform jest nieużywane przez buildy bez V8; na buildzie V8 pusty wskaźnik blokuje JavaScript, podczas gdy dokument się renderuje. Rekord IPDF_JsPlatform, na który wskazuje, ma własne pole version i nagłówek mówi, że ono obecnie musi wynosić 2

Pułapka: pozwolenie dokumentowi wybrać wersję ABI

Błąd to jeden warunek, który sam z siebie brzmi rozsądnie. Ręcznie pisane wiązanie i tak musi zdecydować, czy warto podpiąć callbacki XFA, i kusi, by ten sam test wybrał numer wersji

// Kuszący kształt dla ręcznie pisanego wiązania: wersja idzie za dokumentem
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;   // każdy dokument bez XFA dostaje 1

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// na pdfium.v8.dll ze zwykłym plikiem AcroForm FormHandle jest tu nil

Zamiar tej gałęzi jest defensywny: trzymać wersję na 1, żeby build z XFA nigdy nie czytał slotów, których host nie wypełnił. Ale FillChar linijkę wyżej już je wypełnił NULL-em, który nagłówek definiuje jako poprawną wartość dla hosta nieuruchamiającego XFA. Gałąź strzeże przed zagrożeniem, które deklaracja usunęła, i wchodzi prosto w to, przed którym nagłówek przestrzega. Tego też nie wyłapie testowanie plikami XFA, bo te za każdym razem idą ścieżką wersji 2

PDFium Component buduje rekord na odwrót. Kiedykolwiek TPdf.FormFill jest True, otwarcie dokumentu zeruje cały rekord, wpisuje wersję 2 i xfa_disabled := 1, a dopiero potem pyta, czego dokument potrzebuje. Jako reguła dla dowolnego wiązania: ustaw wersję według zadeklarowanego rekordu i nie pozwól niczemu innemu jej ruszać. Oto ten kształt dla twojego własnego kodu, z wrapperem THostFormFillInfo zadeklarowanym niżej

// FormInfo to pole THostFormFillInfo twojego obiektu przeglądarki, więc przeżywa
// uchwyt formularza; HostInvalidate, HostGetPage i reszta to twoje własne
// callbacki cdecl
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;

FormInfo.Info.version := 2;        // zgodne z zadeklarowanym układem, zawsze
FormInfo.Info.xfa_disabled := 1;   // XFA zostaje wyłączone, dopóki nie udowodni, że działa

FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... pozostałe callbacki wersji 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;
  // ... pozostałe sloty wersji 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);

Gdzie miejsce na RuntimeReady i V8FeaturesAvailable

Sprawdzenia możliwości decydują, które sloty zostają wypełnione, i w PDFium Component nigdy nie dotykają układu. TPdf liczy gotowość z trzech faktów: dokument zgłasza typ formularza XFA przez TPdf.XFA, globalne XfaFeaturesAvailable znajduje pomocniki napisów XFA eksportowane przez wczytaną DLL, a V8FeaturesAvailable znajduje eksporty V8. Callbacki wersji 1 są podpięte dla każdego dokumentu, bo AcroForm i XFA oba na nich polegają. Wszystkie siedemnaście slotów wersji 2 jest przypisywanych, a xfa_disabled zerowane, tylko gdy wszystkie trzy fakty się potwierdzają. Po tym, jak środowisko istnieje, FPDF_LoadXFA odpala się tylko dla tego przypadku, i tylko niezerowy zwrot sprawia, że TPdf.XfaRuntimeAvailable raportuje True. FPDF_LoadXFA jest eksportowane nawet przez buildy bez silnika XFA i zwraca tam po prostu false, dlatego sama kontrola eksportu nie wystarcza

Gdy dokument jest XFA, a runtimeu brak, rekord zostaje na wersji 2 z xfa_disabled równym 1 i ogonem NULL. Środowisko otwiera się, treść XFA pozostaje statyczna, a TPdf podnosi OnXfaRuntimeMissing, żeby host mógł zasugerować restart na pdfium.v8.dll. m_pJsPlatform podlega trzeciej regule: jest podpinane zawsze, gdy V8FeaturesAvailable jest prawdziwe, także dla zwykłych plików AcroForm — to dzięki temu skrypty poziomu dokumentu, wyliczenia pól i app.alert działają na buildzie V8. PageCount czyta statyczne drzewo stron, dopóki XFA nie przepaginuje dokumentu przez FFI_PageEvent; od tego momentu nowa liczba jest brana z FPDF_GetPageCount i zgłaszana przez OnXfaPageCountChanged

Po stronie hosta dwie właściwości odpowiadają na dwa różne pytania. XFA mówi, co zawiera plik, z FPDF_GetFormType, niezależnie od binarki. XfaRuntimeAvailable mówi, czy silnik faktycznie wystartował dla tego dokumentu. Czytaj tę drugą, gdy Active stanie True, jak robi artykuł o wykrywaniu formularzy XFA i wyciąganiu pakietów XFA, i wyłącz pole wersji z rozumowania całkowicie

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Podnoszone, gdy dokument się otwiera: jest XFA, ale wczytana DLL nie umie
  // uruchomić silnika. Środowisko formularza istnieje nadal, z wyłączonym 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;   // zwykłe pliki AcroForm otwierają się też pod pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaNotice;   // twoja własna procedura UI
end;

Błędy układu, które łamią rekord, zanim wersja zostanie przeczytana

Poprawny numer wersji pomaga tylko wtedy, gdy bajty za nim siedzą tam, gdzie postawił je kompilator C. Kilka błędów wiązania daje rekord, który PDFium błędnie odczyta niezależnie od niesionej wersji, a jeden z nich pozostaje niewidoczny na Win32. To deklarowanie FPDF_FORMFILLINFO jako packed record. Na Win32 każde pole ma cztery bajty, więc układ packed i wyrównany akurat się pokrywają. Na Win64 struktura C dokłada cztery bajty wyściółki po version, by Release wylądował na granicy 8 bajtów, i dokłada ponownie po xfa_disabled; pascalska deklaracja packed zrzuca obie przerwy i przesuwa każdy wskaźnik callbacku o cztery bajty. Deklaracja w FPdfFormFill.pas jest z tego powodu zwykłym record

Drugi błąd to ucięty rekord: tłumaczenie kończące się na m_pJsPlatform, które jednak wpisuje wersję 2. PDFium czyta wtedy ogon z dowolnej pamięci za rekordem. Trzeci to czas życia. Nagłówek mówi, że rekord przekazany do FPDFDOC_InitFormFillEnvironment musi być ważny do zamknięcia zwróconego uchwytu formularza, więc rekord trzymany w zmiennej lokalnej procedury otwierającej dokument to wiszący wskaźnik w chwili, gdy procedura wraca. TPdf trzyma swój rekord jako pole komponentu, owinięty w zewnętrzny rekord, który stawia strukturę PDFium na offsecie zero, a wskaźnik zwrotny do właścicielskiego TPdf tuż za nią — w ten sposób każdy statyczny callback odnajduje swój obiekt

type
  // Źle na Win64: pakowanie usuwa wyściółkę, którą struktura C ma po
  // version, więc Release siedzi tu na offsecie 4, a w PDFium na offsecie 8
  TBadFormFillInfo = packed record
    version: Integer;
    Release: Pointer;
    // ... pozostałe pola
  end;

  // Twój własny wrapper: najpierw rekord PDFium, za nim twój wskaźnik zwrotny
  THostFormFillInfo = record
    Info: FPDF_FORMFILLINFO;   // offset 0, więc pThis rzutuje się prosto z powrotem
    Owner: TObject;
  end;
  PHostFormFillInfo = ^THostFormFillInfo;

procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
  left, top, right, bottom: Double); cdecl;
begin
  try
    // TMyViewer i InvalidatePdfRect stoją za twoją własną klasę przeglądarki
    TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
      page, left, top, right, bottom);
  except
    // pascalski wyjątek nie może się rozwijać przez ramki C++ PDFium
  end;
end;

Dwa sloty zasługują na szczególną staranność. FFI_GetLocalTime zwraca 16-bajtowe FPDF_SYSTEMTIME przez wartość, a Delphi i MSVC rozchodzą się na Win32 co do miejsca ukrytego wskaźnika wyniku, więc pascalska implementacja zepsułaby FPDF_FORMFILLINFO wywołującego. Obecny PDFium oznacza tę składową jako nieużywaną, a pascalska deklaracja niesie komentarz, by zostawić ją nil; PDFium Component tak robi. Każdy callback, który komponent instaluje, łapie pascalskie wyjątki i zwraca zamiast nich udokumentowaną wartość porażki, bo ABI C nie ma sposobu, by przenieść wyjątek z powrotem przez granicę. Tekst o hartowaniu ABI PDFium w Delphi przechodzi przez tę dyscyplinę na przykładzie FFI_OpenFile

Wersja protokołu i dostępność funkcji to dwie osie

Pole wersji w strukturze callbacków odpowiada, jak duży jest rekord i co wolno z niego czytać. Wykrywanie funkcji odpowiada, które z tych slotów w ogóle coś zrobi. Pierwsze jest ustalone przez natywną binarkę i przez deklarację, przeciw której się kompilowało; drugie zmienia się per dokument, per tabela eksportów DLL i per ustawienie hosta. Przypadek XFA potrzebuje obu odpowiedzi naraz, co kusi, by zwinąć je w jeden boolean, a to zwinięcie pęka w chwili, gdy build wymusi minimalną wersję. Formularze XFA, które ISO 32000-1 §12.7.8 opisuje jako payload XML niesiony obok słownika AcroForm, są funkcją w tej historii. Układ rekordu to protokół, a PDFium ma prawo sprawdzić układ, zanim przeczyta plik

Diagram PDFium Component rozdzielający dwie osie stojące za FPDF_FORMFILLINFO: wersję protokołu ustaloną przez układ rekordu i natywną binarkę oraz dostępność funkcji, gdzie RuntimeReady bramkuje xfa_disabled, siedemnaście slotów wersji 2 i FPDF_LoadXFA, a V8FeaturesAvailable rozstrzyga 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 łamie build wymuszający minimum

Ta sama struktura pojawia się wszędzie tam, gdzie biblioteka C wersjonuje swoje struktury: FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, dowolna tabela callbacków z nagłówkiem rozmiaru albo wersji. Zadeklaruj najnowszy układ, który rozumiesz, wyzeruj go całkowicie, wpisuj pasującą wersję bezwarunkowo i wypełniaj sloty według tego, co dokument i DLL faktycznie potrafią. Jeśli przyszły fpdf_formfill.h wprowadzi wersję 3, praca to nowa deklaracja i jedna zmieniona instrukcja przypisania. Gałąź kluczowaną treścią dokumentu trzeba by przeglądać od nowa dla każdej kombinacji builda i pliku, a ta, którą się pomyli, to zwykle plik, którego nikt nie otworzył

Obsługa rekordu opisana tutaj jest częścią PDFium Component dla Delphi, Lazarusa i C++Buildera, z tą samą deklaracją obsługującą buildy Win32 i Win64. Aplikacja, która już rozsyła pdfium.v8.dll dla AcroForm napędzanych JavaScriptem, może otwierać resztę swojego archiwum, z XFA albo bez, przez tę jedną binarkę bez specjalnej obsługi środowiska formularzy