Technischer Artikel

FPDF_FORMFILLINFO Version 2 in Delphi: DLL-ABI befolgen

PDFium Component setzt FPDF_FORMFILLINFO.version jetzt für jede Form-Fill-Umgebung, die sie initialisiert, auf 2, denn die Version, die ein nativer PDFium-Build akzeptiert, ist eine Eigenschaft dieses Builds, nicht des Dokuments, das geöffnet wird. Eine XFA-fähige pdfium.v8.dll lehnt Version 1 rundheraus ab, sodass ein schlichtes AcroForm-PDF, das damit geöffnet wurde, vorher in FPDFDOC_InitFormFillEnvironment scheiterte, ohne dass irgendwo XFA in Sicht gewesen wäre. Der Fix in v3.116.0 ist klein, aber der Fehler dahinter ist ein allgemeiner und lohnt eine Benennung: Ein Protokoll-Versionsfeld beschreibt das Speicherlayout, das die Gegenseite erwartet, und es darf niemals daraus abgeleitet werden, ob man zufällig die Features braucht, die dieses Layout trägt

Warum scheitert FPDFDOC_InitFormFillEnvironment an einem schlichten PDF mit pdfium.v8.dll?

Die Umgebung scheitert, weil ein XFA-fähiger PDFium-Build das version-Feld validiert, bevor er irgendetwas anderes tut, und die alte Wrapper-Logik ihm eine 1 gab, sobald das aktuelle Dokument kein XFA-Formular war. Das Symptom in einem Delphi-Host ist eine EPdfError aus TPdf.InitializeFormFill mit der Meldung Cannot initialize form fill environment, geworfen beim Öffnen einer gewöhnlichen Rechnung oder eines Steuerformulars, das nichts als AcroForm-Textfelder hat. Dieselbe Datei öffnet gegen die schlichte pdfium.dll problemlos. Dieselbe DLL öffnet ein echtes XFA-Dokument problemlos. Nur die Kombination aus V8-Build und Nicht-XFA-Dokument bricht – genau die Kombination, in der ein Host landet, nachdem er EnableV8Engine eingeschaltet hat, um AcroForm-JavaScript zu bekommen, oder nachdem die Auto-Auswahl in LoadDocument den Prozess für eine frühere XFA-Datei bereits an pdfium.v8.dll gebunden hat. Diese Bindung gilt prozessweit: EnableV8Engine wird vor dem ersten LoadLibrary gelesen, und sobald der XFA-Build geladen ist, durchläuft jedes spätere schlichte PDF dasselbe Umgebungs-Setup gegen dieselbe Binary. Der Host hat nichts falsch gemacht; der Wrapper hat beim Ausfüllen des Records die falsche Frage gestellt. Wer noch im Klären ist, welche Binary überhaupt ausgeliefert werden soll, findet in unserer Notiz zum Deployen der PDFium-DLL und zur Diagnose von Lade-Fehlern die Auswahl zwischen Plain und V8; dieser Artikel setzt voraus, dass der V8-Build bereits im Prozess ist

PDFium-Component-Diagramm der vier Kombinationen aus schlichter pdfium.dll und XFA-fähiger pdfium.v8.dll gegen AcroForm- und XFA-Dokumente: Ein Version-1-Record brach nur den V8-Build mit schlichtem Formular, EPdfError in FPDFDOC_InitFormFillEnvironment, während der korrigierte Version-2-Record alle vier öffnet
Ein einziges Conditional koppelte die ABI-Version an das Dokument, sodass die prozessweite Wahl der V8-Binary jedes spätere schlichte PDF in eine fehlgeschlagene Umgebungs-Initialisierung verwandelte

Was verspricht das version-Feld in FPDF_FORMFILLINFO eigentlich?

FPDF_FORMFILLINFO.version sagt PDFium, welche Felder des Records es lesen darf, und der öffentliche Header fpdf_formfill.h knüpft die akzeptablen Werte daran, wie die Bibliothek kompiliert wurde, nicht an das Dokument. Sinngemäß hat der Vertrag drei Teile. Version 1 umfasst die stabilen Callbacks von FFI_Invalidate bis FFI_DoGoToAction plus den m_pJsPlatform-Zeiger. Ein Build ohne XFA-Modul akzeptiert 1 oder 2, und bei 2 ruft er zusätzlich die experimentellen Callbacks auf. Ein Build mit XFA-Modul verlangt 2, Punkt, und der Header wiederholt diese Anforderung zweimal, als hätte er befürchtet, Leute könnten sie überlesen. Nirgends erwähnt der Vertrag das Dokument. Die Version ist eine Aussage über den Record, den Sie alloziert haben: Mit einer 2 versprechen Sie, dass der Speicher hinter m_pJsPlatform existiert und entweder gültige Funktionszeiger oder NULL hält

Die Version-2-Region ist, wo die gesamte XFA-Maschinerie wohnt. Sie beginnt mit xfa_disabled, einem FPDF_BOOL, den der Header als unterhalb von Version 2 ignoriert und nur bei einkompiliertem XFA-Modul relevant beschreibt, und setzt sich mit siebzehn Funktionszeigern fort, FFI_DisplayCaret bis FFI_DoURIActionWithKeyboardModifier. Jeder davon ist dokumentiert als für XFA erforderlich und andernfalls auf NULL zu setzen. Diese Formulierung ist der Schlüssel zum ganzen Fix. NULL ist für diese Slots kein Fehlerzustand; es ist der dokumentierte Zustand für einen Host, der XFA nicht antreibt. Ein Record, der mit FillChar geleert und dann als Version 2 markiert wurde, erfüllt den Vertrag auf einem Nicht-XFA-Build genauso gut wie ein Version-1-Record, und er ist der einzige Record, den ein XFA-Build annimmt

PDFium-Component-Diagramm des FPDF_FORMFILLINFO-Records in Delphi: Version 1 umfasst die Callbacks von FFI_Invalidate bis FFI_DoGoToAction plus m_pJsPlatform, Version 2 ergänzt xfa_disabled und siebzehn Zeiger aus der FFI_DisplayCaret-Ära, FillChar leert jedes Byte, und NULL-Slots sind der dokumentierte Zustand für einen Host, der XFA nicht antreibt
Der Pascal-Record ist immer das vollständige Version-2-Layout, also akzeptiert ihn ein XFA-fähiger Build, und ein schlichter Build ruft die NULL bleibenden experimentellen Slots schlicht nie auf

Die alte Auswahl koppelte den ABI an das Dokument

Der Defekt war ein einziges Conditional, das isoliert betrachtet vernünftig aussah. TPdf.InitializeFormFill berechnet ein RuntimeReady-Flag aus drei Fakten: Das Dokument meldet über TPdf.XFA einen XFA-Formulartyp, die XFA-String-Helfer sind über XfaFeaturesAvailable aufgelöst, und die V8-Exports sind über V8FeaturesAvailable aufgelöst. Vor v3.116.0 wählte dasselbe Flag auch die Version

// v3.115.0 und älter: die ABI-Version folgte dem Dokument
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;

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

// ... und der Runtime-missing-Zweig nagelte sie erneut fest
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

Liest man das mit dem Header in der Hand, ist das Scheitern offensichtlich. RuntimeReady ist für jedes schlichte AcroForm-Dokument false, also kündigte jedes schlichte Dokument Version 1 an. Auf pdfium.dll ist das fein. Auf pdfium.v8.dll, dem XFA-fähigen Build, prüft PDFium das Feld, findet es unterhalb der geforderten 2 und gibt einen NULL-FPDF_FORMHANDLE zurück, den CheckPdf in die obige Exception übersetzt. Die Absicht des alten Codes war defensiv: Version 1 halten, damit ein XFA-Build nie die unbelegten Version-2-Slots liest. Er verteidigte gegen ein Problem, das der Header längst ausschließt, und schuf eines, vor dem der Header ausdrücklich warnt. Der korrigierte Code entscheidet die Version einmalig, vorab, danach, was der Record physisch ist

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // Sentinel: den statischen Seitenbaum benutzen
  if not FormFill then
    Exit;

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

  // Der vollständige Version-2-Record ist oben alloziert und geleert. PDFium
  // akzeptiert Version 2 ohne XFA und verlangt sie in jedem XFA-fähigen
  // Build, auch wenn dieses Dokument kein XFA-Formular enthält.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady gate-t die XFA-Callbacks und xfa_disabled, nie die Version.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

Wo RuntimeReady weiterhin hingehört: die Callbacks und xfa_disabled

RuntimeReady behält seinen Job als Tor für XFA-Verhalten; es fasst das Record-Layout nur nicht mehr an. Die Version-1-Callbacks – FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction und der Rest dieses Blocks – werden bedingungslos verdrahtet, denn AcroForm und XFA hängen beide davon ab. Die siebzehn Version-2-Zeiger werden nur innerhalb des RuntimeReady-Zweigs zugewiesen, zusammen mit xfa_disabled := 0. Ist das Dokument XFA, aber die Runtime nicht da, bleibt der Record bei Version 2 mit xfa_disabled auf 1 und den NULL gelassenen Version-2-Slots, und der Wrapper wirft OnXfaRuntimeMissing, damit der Host einen Neustart auf pdfium.v8.dll vorschlagen kann. Nachdem die Umgebung existiert, wird FPDF_LoadXFA nur aufgerufen, wenn RuntimeReady true war, und nur ein true-Rückgabewert setzt FXfaRuntimeUsable, was TPdf.XfaRuntimeAvailable meldet

  if RuntimeReady then
  begin
    FFormFillInfo.Info.xfa_disabled := 0;   // 0 = XFA aktiviert
    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 bis FFI_DoURIActionWithKeyboardModifier
  end
  else if XFA then
  begin
    // Runtime nicht verfügbar: Version 2 halten, XFA deaktiviert lassen, Host informieren.
    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;

Zwei Details in diesem Block gehen beim Schreiben des eigenen Bindings leicht schief. FXfaPageCountOverride wird vor allem anderen als Sentinel auf -1 zurückgesetzt, sodass PageCount auf den statischen Seitenbaum zurückfällt, bis FFI_PageEvent eine Neuverteilung meldet; eine null dort würde stillschweigend ein leeres Dokument behaupten. Und jeder der Version-2-Callbacks ist eine statische cdecl-Routine, die den besitzenden TPdf aus dem Record zurückgewinnt und jede Pascal-Exception schluckt, bevor sie zu PDFium zurückkehrt – die Disziplin, die unsere Notiz zum Hardening des PDFium-ABI in Delphi für FFI_OpenFile ausbuchstabiert. An der Versionsänderung lockert keine der beiden Regeln

Ist Version 2 sicher, wenn die DLL kein XFA-Modul hat?

Ja, und der Grund liegt im Record, nicht in einem Versprechen der Bibliothek. Auf einem Nicht-XFA-Build sagt der Header, Version 2 lasse auch die experimentellen Callbacks aufrufen, also ist die Frage, was PDFium findet, wenn es hinsieht. TPdfFormFillInfo ist ein packed Record, dessen Info-Member das vollständige FPDF_FORMFILLINFO einschließlich jedes Version-2-Felds ist, und InitializeFormFill leert das Ganze mit FillChar, bevor es ein Byte anfasst. Auf einer schlichten pdfium.dll mit einem schlichten Dokument sieht die Bibliothek also Version 2, gesetztes xfa_disabled und NULL in jedem experimentellen Slot – exakt der Zustand, den der Header für einen Host vorschreibt, der XFA nicht implementiert. Es gibt keinen abgeschnittenen Record, über den die Bibliothek hinauslesen könnte, denn der Record war nie kürzer als Version 2. Die alte Logik verteidigte eine Layout-Diskrepanz, die die Pascal-Deklaration längst eliminiert hatte

Die Grenze, die man ehrlich benennen sollte, ist die, die der Record nicht abdecken kann. Version 2 auf einem schlichten Dokument schaltet weder JavaScript noch XFA-Scripting noch eines der Host-Events hinter diesen Callbacks ein. m_pJsPlatform wird nur angehängt, wenn V8FeaturesAvailable true ist, XFA bleibt deaktiviert, solange RuntimeReady nicht true war, und TPdf.XFA meldet weiter den Formulartyp aus FPDF_GetFormType, ganz gleich, was die Umgebung ausgehandelt hat. Ein Host, der wissen will, ob dynamisches XFA tatsächlich rendert, sollte XfaRuntimeAvailable weiter lesen, nachdem Active true geworden ist, wie unsere Notiz zum Erkennen von XFA-Formularen und Extrahieren von XFA-Paketen empfiehlt, statt irgendetwas aus dem Versionsfeld zu schließen

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Feuert aus InitializeFormFill, wenn das Dokument XFA ist, die geladene
  // pdfium.dll die Engine aber nicht laufen lassen kann. Die Form-Umgebung
  // öffnet trotzdem, denn Version 2 ging so oder so durch; nur die XFA-Runtime ist aus.
  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;   // wirft auf einem schlichten PDF unter pdfium.v8.dll nicht mehr
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

Protokollversion und Feature-Verfügbarkeit sind zwei verschiedene Achsen

Die allgemeine Regel, die aus diesem Fix herausfällt: Ein Versionsfeld in einer Callback-Struktur beantwortet die Frage „wie groß ist dieser Record und was dürfen Sie daraus lesen“, während Feature-Erkennung beantwortet, „welche dieser Slots tun überhaupt etwas Nützliches“. Das Erste ist fixiert durch die native Binary und durch die Pascal-Deklaration, gegen die Sie kompiliert haben. Das Zweite variiert pro Dokument, pro DLL-Export-Tabelle und pro Host-Konfiguration. Die beiden in einen Boolean zu quetschen ist verlockend, weil der XFA-Fall zufällig beides braucht, aber in dem Moment, in dem ein Build eine Mindestversion erzwingt, bricht die Quetschung für jedes Dokument, das das Feature nicht braucht. XFA-Formulare, in ISO 32000-1 §12.7.8 als XML-Payload beschrieben, die neben dem AcroForm-Dictionary lebt, sind hier das Feature; das Record-Layout ist das Protokoll, und PDFium darf darauf bestehen, das Layout zu prüfen, bevor es je einen Blick in die Datei wirft. Dieselbe Form zeigt sich überall, wo eine C-Bibliothek ihre Strukturen versioniert: ein Viewer-Info-Block, ein Render-Options-Record, eine Plattform-Callback-Tabelle. Das sichere Muster ist das, dem der korrigierte InitializeFormFill folgt. Deklarieren Sie das neueste Layout, das Sie verstehen, leeren Sie es vollständig, setzen Sie die Version bedingungslos passend zu diesem Layout, und lassen Sie dann Capability-Prüfungen entscheiden, welche Slots befüllt werden. Fügt ein künftiger PDFium-Header eine Version 3 hinzu, ändert sich die Deklaration und diese eine Zuweisung – nicht ein dokumentabhängiger Zweig, der für jede Kombination falsch wäre, die niemand getestet hat

PDFium-Component-Diagramm, das die zwei Achsen hinter FPDF_FORMFILLINFO trennt: die Protokollversion, fixiert durch Record-Layout und native Binary, und die Feature-Verfügbarkeit, wo RuntimeReady pro Dokument und Host xfa_disabled, siebzehn Version-2-Slots, FPDF_LoadXFA und m_pJsPlatform gate-t
Ein Versionsfeld beschreibt den Speicher, den die Gegenseite lesen darf, Capability-Prüfungen entscheiden, welche Slots etwas Nützliches tun, und die beiden in einen Boolean zu quetschen bricht den Build, der ein Minimum erzwingt

Die korrigierte Form-Fill-Initialisierung steckt im PDFium Component für Delphi, Lazarus und C++Builder und greift auf Win32 wie Win64, da beide Builds dieselbe Record-Deklaration teilen. Wer seine Anwendung pdfium.v8.dll für JavaScript-getriebene AcroForms bereits auswählen lässt, bekommt hier die Änderung, die den Rest des PDF-Archivs durch dieselbe Binary öffnet, ohne die Form-Umgebung zu sonderbehandeln