Technisch artikel

FPDF_FORMFILLINFO versie 2 in Delphi: volg de DLL-ABI

PDFium Component zet FPDF_FORMFILLINFO.version nu op 2 voor elke form-fill-omgeving die het initialiseert, omdat de versie die een native PDFium-build accepteert een eigenschap van die build is, niet van het document dat wordt geopend. Een pdfium.v8.dll met XFA weigert versie 1 botweg, dus een gewone AcroForm-PDF die erdoor werd geopend faalde vroeger in FPDFDOC_InitFormFillEnvironment zonder dat er ergens XFA in zicht was. De fix in v3.116.0 is klein, maar de fout erachter is algemeen en het benoemen waard: een protocolversieveld beschrijft de geheugenlayout die de andere kant verwacht, en het mag nooit worden afgeleid uit de vraag of je toevallig de features nodig hebt die die layout draagt

Waarom faalt FPDFDOC_InitFormFillEnvironment op een gewone PDF met pdfium.v8.dll?

De omgeving faalt omdat een XFA-pdfium-build het veld version valideert voordat hij iets anders doet, en de oude wrapperlogica gaf hem een 1 wanneer het huidige document geen XFA-formulier was. Het symptoom in een Delphi-host is een EPdfError die vanuit TPdf.InitializeFormFill wordt opgegooid met de melding Cannot initialize form fill environment, tijdens het openen van een gewone factuur of een belastingformulier met niets anders dan AcroForm-tekstvelden. Hetzelfde bestand opent prima tegen de gewone pdfium.dll. Dezelfde DLL opent een echt XFA-document prima. Alleen de combinatie van de V8-build en een niet-XFA-document breekt, en dat is precies de combinatie waarin een host belandt nadat hij EnableV8Engine aanzet om AcroForm-JavaScript te krijgen, of nadat de automatische selectie in LoadDocument het proces al aan pdfium.v8.dll heeft vastgelegd voor een eerder XFA-bestand. Die vastlegging geldt procesbreed: EnableV8Engine wordt gelezen vóór de eerste LoadLibrary, en zodra de XFA-build is geladen gaat elke latere gewone PDF door dezelfde omgevingsopzet tegen dezelfde binary. De host deed niets fout; de wrapper stelde de verkeerde vraag toen hij het record invulde. Als je nog moet beslissen welke binary je überhaupt meelevert, dan behandelt onze notitie over het uitrollen van de PDFium-DLL en het diagnosticeren van laadfouten de keuze tussen gewoon en V8, en dit artikel gaat ervan uit dat de V8-build al in het proces zit

Diagram van PDFium Component: de vier combinaties van gewone pdfium.dll en XFA-pdfium.v8.dll tegen AcroForm- en XFA-documenten, waarbij een versie-1-record alleen de V8-build met een gewoon formulier brak, EPdfError in FPDFDOC_InitFormFillEnvironment, terwijl het gecorrigeerde versie-2-record alle vier opent
Eén voorwaardelijke koppelde de ABI-versie aan het document, dus de procesbrede keuze van de V8-binary maakte van elke latere gewone PDF een mislukte omgevingsinitialisatie

Wat belooft het versieveld in FPDF_FORMFILLINFO nu eigenlijk?

FPDF_FORMFILLINFO.version vertelt PDFium welke velden van het record het mag lezen, en de publieke header fpdf_formfill.h koppelt de toegestane waarden aan hoe de library is gecompileerd en niet aan het document. In parafrase heeft het contract drie delen. Versie 1 dekt de stabiele callbacks van FFI_Invalidate tot en met FFI_DoGoToAction plus de pointer m_pJsPlatform. Een build zonder de XFA-module accepteert 1 of 2, en met 2 roept hij ook de extra experimentele callbacks aan. Een build met de XFA-module eist 2, punt, en de header herhaalt die eis twee keer alsof hij verwacht dat mensen hem missen. Nergens noemt het contract het document. De versie is een uitspraak over het record dat je hebt gealloceerd: met een 2 beloof je dat het geheugen na m_pJsPlatform bestaat en ofwel geldige functiepointers ofwel NULL bevat

In het versie-2-gebied woont alle XFA-machinerie. Het begint met xfa_disabled, een FPDF_BOOL die de header beschrijft als genegeerd onder versie 2 en alleen betekenisvol wanneer de XFA-module is meegecompileerd, en gaat verder met zeventien functiepointers, van FFI_DisplayCaret tot FFI_DoURIActionWithKeyboardModifier. Elk daarvan is gedocumenteerd als verplicht voor XFA en anders op NULL te zetten. Die formulering is de sleutel tot de hele fix. NULL is voor die slots geen fouttoestand; het is de gedocumenteerde toestand voor een host die geen XFA aanstuurt. Een record dat met FillChar is gewist en daarna als versie 2 is gemarkeerd voldoet op een niet-XFA-build precies zo goed aan het contract als een versie-1-record, en het is het enige record dat een XFA-build aanneemt

Diagram van PDFium Component: het record FPDF_FORMFILLINFO in Delphi, waarbij versie 1 de callbacks FFI_Invalidate tot en met FFI_DoGoToAction plus m_pJsPlatform dekt, versie 2 xfa_disabled en zeventien pointers uit het FFI_DisplayCaret-tijdperk toevoegt, FillChar elke byte wist, en NULL-slots de gedocumenteerde toestand zijn voor een host die geen XFA aanstuurt
Het Pascal-record heeft altijd de volledige versie-2-layout, dus een XFA-build accepteert het en een gewone build roept de experimentele slots die NULL blijven simpelweg nooit aan

De oude selectie koppelde de ABI aan het document

Het defect was één enkele voorwaardelijke die op zichzelf redelijk leek. TPdf.InitializeFormFill berekent een flag RuntimeReady uit drie feiten: het document meldt een XFA-formuliertype via TPdf.XFA, de XFA-stringhelpers zijn opgelost via XfaFeaturesAvailable, en de V8-exports zijn opgelost via V8FeaturesAvailable. Vóór v3.116.0 koos diezelfde flag ook de versie

// v3.115.0 en eerder: de ABI-versie volgde het document
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;

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

// ... en de tak voor ontbrekende runtime zette hem nog eens vast
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

Lees het met de header in de hand en de fout is overduidelijk. RuntimeReady is onwaar voor elk gewoon AcroForm-document, dus elk gewoon document kondigde versie 1 aan. Op pdfium.dll is dat prima. Op pdfium.v8.dll, de XFA-build, controleert PDFium het veld, vindt het onder de vereiste 2, en geeft een null FPDF_FORMHANDLE terug, die CheckPdf omzet in de exception hierboven. De bedoeling van de oude code was defensief: versie 1 aanhouden zodat een XFA-build nooit de niet-toegewezen versie-2-slots leest. Hij verdedigde tegen een probleem dat de header al uitsluit en creëerde er een waarvoor de header expliciet waarschuwt. De gecorrigeerde code bepaalt de versie één keer, vooraf, op basis van wat het record fysiek is

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // sentinel: gebruik de statische paginaboom
  if not FormFill then
    Exit;

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

  // Het volledige versie-2-record is hierboven gealloceerd en gewist. PDFium
  // accepteert versie 2 zonder XFA en eist hem in elke XFA-build,
  // ook wanneer dit document geen XFA-formulier bevat.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady begrenst de XFA-callbacks en xfa_disabled, nooit de versie.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

Waar RuntimeReady nog wel hoort: de callbacks en xfa_disabled

RuntimeReady houdt zijn rol als begrenzing van XFA-gedrag; hij raakt alleen de recordlayout niet meer aan. De versie-1-callbacks, FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction en de rest van dat blok, worden onvoorwaardelijk bedraad omdat AcroForm en XFA beide van hen afhangen. De zeventien versie-2-pointers worden alleen binnen de RuntimeReady-tak toegewezen, samen met xfa_disabled := 0. Wanneer het document XFA is maar de runtime er niet is, blijft het record op versie 2 met xfa_disabled op 1 en blijven de versie-2-slots NULL, en gooit de wrapper OnXfaRuntimeMissing op zodat de host kan voorstellen om op pdfium.v8.dll te herstarten. Nadat de omgeving bestaat wordt FPDF_LoadXFA alleen aangeroepen wanneer RuntimeReady waar was, en alleen een true-return zet FXfaRuntimeUsable, wat TPdf.XfaRuntimeAvailable rapporteert

  if RuntimeReady then
  begin
    FFormFillInfo.Info.xfa_disabled := 0;   // 0 = XFA ingeschakeld
    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;
    // ... FFI_UploadTo tot FFI_DoURIActionWithKeyboardModifier
  end
  else if XFA then
  begin
    // Runtime niet beschikbaar: houd versie 2, laat XFA uitgeschakeld, meld het de host.
    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;

Twee details in dat blok zijn makkelijk fout te doen wanneer je je eigen binding schrijft. FXfaPageCountOverride wordt als sentinel op -1 gezet voordat er iets anders gebeurt, zodat PageCount terugvalt op de statische paginaboom tot FFI_PageEvent een herindeling meldt; een nul daar zou stilzwijgend een leeg document beweren. En elk van de versie-2-callbacks is een statische cdecl-routine die de eigenaar-TPdf uit het record haalt en elke Pascal-exception inslikt voordat hij terugkeert naar PDFium, en dat is de discipline die onze notitie over het harden van de PDFium-ABI in Delphi uiteenzet voor FFI_OpenFile. Niets aan de versiewijziging versoepelt een van beide regels

Is versie 2 veilig wanneer de DLL geen XFA-module heeft?

Ja, en de reden zit in het record, niet in een belofte van de library. Op een niet-XFA-build zegt de header dat versie 2 de experimentele callbacks ook laat aanroepen, dus de vraag is wat PDFium aantreft als het kijkt. TPdfFormFillInfo is een packed record waarvan het lid Info de complete FPDF_FORMFILLINFO is, inclusief elk versie-2-veld, en InitializeFormFill wist het hele ding met FillChar voordat het een byte aanraakt. Dus op een gewone pdfium.dll met een gewoon document ziet de library versie 2, xfa_disabled gezet, en NULL in elk experimenteel slot, precies de toestand die de header voorschrijft voor een host die geen XFA implementeert. Er is geen afgekapt record waar de library voorbij kan lezen, want het record is nooit korter geweest dan versie 2. De oude logica verdedigde tegen een layoutverschil dat de Pascal-declaratie al had weggenomen

De grens die je eerlijk moet benoemen is degene die het record niet kan dekken. Versie 2 op een gewoon document zet geen JavaScript, XFA-scripting of welke host-events dan ook achter die callbacks aan. m_pJsPlatform wordt alleen gekoppeld wanneer V8FeaturesAvailable waar is, XFA blijft uitgeschakeld tenzij RuntimeReady waar was, en TPdf.XFA blijft het formuliertype uit FPDF_GetFormType rapporteren ongeacht wat de omgeving heeft onderhandeld. Een host die wil weten of dynamische XFA werkelijk gaat renderen, kan beter XfaRuntimeAvailable blijven lezen nadat Active waar is geworden, zoals onze notitie over het detecteren van XFA-formulieren en het extraheren van XFA-pakketten aanraadt, in plaats van iets af te leiden uit het versieveld

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Wordt vanuit InitializeFormFill geactiveerd wanneer het document XFA is maar de geladen
  // pdfium.dll de engine niet kan draaien. De formulieromgeving opent nog steeds,
  // omdat versie 2 hoe dan ook is doorgegeven; alleen de XFA-runtime staat uit.
  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;   // gooit niet langer een fout op een gewone PDF onder pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

Protocolversie en featurebeschikbaarheid zijn twee verschillende assen

De algemene regel die uit deze fix volgt is dat een versieveld in een callbackstructuur de vraag beantwoordt "hoe groot is dit record en wat mag je eruit lezen", terwijl featuredetectie antwoordt "welke van die slots doen iets nuttigs". Het eerste wordt bepaald door de native binary en door de Pascal-declaratie waartegen je hebt gecompileerd. Het tweede verschilt per document, per DLL-exporttabel en per hostconfiguratie. De twee samenvoegen tot één boolean is verleidelijk omdat het XFA-geval toevallig beide nodig heeft, maar op het moment dat een build een minimumversie afdwingt breekt die samenvoeging voor elk document dat de feature niet nodig heeft. XFA-formulieren, in ISO 32000-1 §12.7.8 beschreven als een XML-payload die naast het AcroForm-woordenboek leeft, zijn hier de feature; de recordlayout is het protocol, en PDFium mag op de layout aandringen voordat het überhaupt naar het bestand kijkt. Dezelfde vorm duikt overal op waar een C-library zijn structuren versioneert: een viewer-info-blok, een renderopties-record, een platform-callbacktabel. Het veilige patroon is het patroon dat de gecorrigeerde InitializeFormFill volgt. Declareer de nieuwste layout die je begrijpt, maak hem volledig leeg, zet de versie onvoorwaardelijk op die layout, en laat capability-checks daarna beslissen welke slots je vult. Voegt een toekomstige PDFium-header een versie 3 toe, dan verandert de declaratie en die ene toewijzing, niet een documentafhankelijke vertakking die fout zal zijn voor welke combinatie dan ook die niemand heeft getest

Diagram van PDFium Component dat de twee assen achter FPDF_FORMFILLINFO scheidt: de protocolversie die vastligt in de recordlayout en de native binary, en featurebeschikbaarheid waarbij RuntimeReady xfa_disabled, zeventien versie-2-slots, FPDF_LoadXFA en m_pJsPlatform per document en per host begrenst
Een versieveld beschrijft het geheugen dat de andere kant mag lezen, capability-checks beslissen welke slots iets nuttigs doen, en de twee samenvoegen tot één boolean breekt de build die een minimum afdwingt

De gecorrigeerde form-fill-initialisatie zit in PDFium Component voor Delphi, Lazarus en C++Builder, en ze geldt voor Win32 en Win64 allebei omdat beide builds dezelfde recorddeclaratie delen. Als je applicatie al pdfium.v8.dll kiest voor JavaScript-gestuurde AcroForms, dan is dit de wijziging waarmee hij de rest van je PDF-archief door dezelfde binary kan openen zonder de formulieromgeving apart te behandelen