Eine AcroForm-Rechnung mit einer Handvoll Textfelder und keinem XFA-Inhalt öffnet durch eine schlichte pdfium.dll problemlos, doch dieselbe Datei kann FPDFDOC_InitFormFillEnvironment dazu bringen, NULL zurückzugeben, sobald der Prozess die XFA-fähige pdfium.v8.dll fährt. Die Bytes des Dokuments sind in beiden Läufen identisch. Unterschiedlich ist eine einzige Ganzzahl: XFA-Builds von PDFium verlangen, dass FPDF_FORMFILLINFO.version 2 ist, und ein Binding, das für Dokumente ohne XFA eine 1 schreibt, wird abgewiesen, bevor PDFium einen einzigen Callback ansieht
Warum FPDFDOC_InitFormFillEnvironment ein schlichtes AcroForm-PDF abweist
Die Zurückweisung kommt aus der Binary, und das Dokument hat kein Wort mitzureden. Der Form-Fill-Header, fpdf_formfill.h in PDFium und seine Pascal-Übersetzung in FPdfFormFill.pas, hält fest, dass ein Build mit einkompiliertem XFA-Modul Version 2 verlangt, und er sagt das zweimal im selben Kommentarblock. Ein Build ohne XFA akzeptiert 1 oder 2. Unter einem Binding, das Version 1 für Nicht-XFA-Dateien schickt, scheitert von den vier Kombinationen aus Binary und Dokument genau eine: ein Version-1-Record, übergeben an pdfium.v8.dll, zusammen mit einer gewöhnlichen AcroForm-Datei. Die schlichte DLL nimmt diesen Record. Die V8-DLL nimmt einen Version-2-Record für jedes Dokument. Ein echtes XFA-Formular auf der V8-DLL bekommt von einem Binding, das die Version am Dokument festmacht, nie Version 1, die fehlschlagende Zelle ist also exakt die, an die niemand beim Testen denkt
Ein handgeschriebenes Binding sieht an diesem Punkt, dass FPDFDOC_InitFormFillEnvironment NULL zurückgibt, und hat nichts Weiteres, woran es sich festhalten könnte. TPdf im PDFium Component würde eine abgewiesene Umgebung als EPdfError während Active := True melden, aber es schreibt immer Version 2, diese Zelle tritt dort also nie auf. In diese Zelle zu geraten ist leicht. Ein Host setzt das globale EnableV8Engine auf True, weil er AcroForm-JavaScript will, und von da an läuft jedes PDF im Prozess durch die V8-Binary. Oder der Host fasst das Flag nie an, aber die erste Datei, die er öffnet, hat einen /XFA-Eintrag in ihrem AcroForm-Dictionary oder /NeedsRendering true in ihrem Katalog: Solange die PDFium-Bibliothek noch nicht geladen ist, scannt TPdf die Datei nach diesen zwei Markern und schaltet EnableV8Engine ein, bevor es die DLL lädt. In beiden Fällen hält die Wahl für den Rest des Prozesses, und jede danach geöffnete schlichte AcroForm-Datei trifft auf den XFA-Build. Welche Binary überhaupt ausgeliefert werden soll, behandelt die Anleitung zum Deployen der PDFium-DLL und zur Diagnose von Lade-Fehlern; alles Folgende setzt voraus, dass der V8-Build bereits geladen ist
Was das version-Feld in FPDF_FORMFILLINFO verspricht
FPDF_FORMFILLINFO.version sagt PDFium, wie viel vom Record es lesen darf. Version 1 umfasst den stabilen Teil: Release, die Callbacks von FFI_Invalidate bis FFI_DoGoToAction und den m_pJsPlatform-Zeiger. Version 2 ergänzt einen experimentellen Schwanz. Ein Build ohne XFA, der Version 2 bekommt, ruft zusätzlich die experimentellen Callbacks auf, die er dort findet; ein Build mit XFA akzeptiert nichts Geringeres. Der Header erwähnt das Dokument nie. Wer eine 2 schreibt, verspricht, dass der Speicher hinter m_pJsPlatform existiert und dass jeder Slot darin entweder eine gültige cdecl-Funktion oder NULL hält
Dieser Schwanz beginnt mit xfa_disabled, einem FPDF_BOOL, das unterhalb von Version 2 ignoriert wird, gefolgt von siebzehn Funktionszeigern von FFI_DisplayCaret bis FFI_DoURIActionWithKeyboardModifier. Fünfzehn davon, FFI_DisplayCaret bis FFI_PutRequestURL, sind als "Required for XFA, otherwise set to NULL" dokumentiert. Die letzten beiden, FFI_OnFocusChange und FFI_DoURIActionWithKeyboardModifier, sind für jeden Host optional, und der Header ergänzt, dass ein Version-2-Embedder, der FFI_DoURIActionWithKeyboardModifier frei lässt, seine URI-Klicks auf FFI_DoURIAction umgeleitet bekommt. Zusammengenommen ist NULL der spezifizierte Wert für einen Host, der XFA nicht antreibt. Ein mit FillChar genullter Record mit Stempel Version 2 erfüllt den Vertrag auf einem schlichten Build genau so wie ein Version-1-Record, und er ist auch der eine Record, den ein XFA-Build akzeptiert
Die JavaScript-Seite folgt demselben Muster. m_pJsPlatform bleibt bei Builds ohne V8 unbenutzt; auf einem V8-Build stoppt ein NULL-Zeiger JavaScript, während das Dokument rendert. Der IPDF_JsPlatform-Record, auf den er zeigt, hat sein eigenes version-Feld, und der Header sagt, dass dieses aktuell 2 sein muss
Die Falle: das Dokument die ABI-Version wählen lassen
Der Fehler ist ein einziges Conditional, das für sich gelesen vernünftig aussieht. Ein handgeschriebenes Binding muss ohnehin entscheiden, ob sich die XFA-Callbacks zu verdrahten lohnen, und es liegt nahe, denselben Test die Versionsnummer wählen zu lassen
// Eine verführerische Form für ein handgeschriebenes Binding: die Version folgt dem Dokument
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; // jedes Nicht-XFA-Dokument bekommt 1
FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// auf pdfium.v8.dll mit einer schlichten AcroForm-Datei ist FormHandle hier nil
Die Absicht hinter diesem Zweig ist defensiv: Die Version auf 1 halten, damit ein XFA-Build nie Slots liest, die der Host nicht befüllt hat. Aber das FillChar in der Zeile darüber hat sie bereits mit NULL befüllt, was der Header als korrekten Wert für einen Host definiert, der XFA nicht laufen lässt. Der Zweig verteidigt gegen eine Gefahr, die die Deklaration längst beseitigt hat, und läuft geradewegs in die hinein, vor der der Header warnt. Erwischt wird er auch nicht durch Tests mit XFA-Dateien, denn die nehmen jedes Mal den Version-2-Pfad
PDFium Component baut den Record genau umgekehrt auf. Sobald TPdf.FormFill True ist, leert das Öffnen eines Dokuments den vollständigen Record, schreibt Version 2 und xfa_disabled := 1 und fragt erst danach, was das Dokument braucht. Als Regel für jedes Binding: Die Version aus dem Record setzen, den man deklariert hat, und nichts anderes daran fassen. Hier ist diese Form für den eigenen Code, mit einem weiter unten deklarierten THostFormFillInfo-Wrapper
// FormInfo ist ein THostFormFillInfo-Feld Ihres Viewer-Objekts, es überlebt also
// das Form-Handle; HostInvalidate, HostGetPage und der Rest sind eigene
// cdecl-Callbacks
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;
FormInfo.Info.version := 2; // passt immer zum deklarierten Layout
FormInfo.Info.xfa_disabled := 1; // XFA bleibt aus, solange nicht als benutzbar bewiesen
FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... die übrigen Version-1-Callbacks
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;
// ... die restlichen Version-2-Slots
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);
Wo RuntimeReady und V8FeaturesAvailable hingehören
Capability-Prüfungen entscheiden, welche Slots befüllt werden, und im PDFium Component fassen sie das Layout nie an. TPdf berechnet die Bereitschaft aus drei Fakten: Das Dokument meldet über TPdf.XFA einen XFA-Formulartyp, das globale XfaFeaturesAvailable findet die XFA-String-Helfer, die die geladene DLL exportiert, und V8FeaturesAvailable findet die V8-Exports. Die Version-1-Callbacks werden für jedes Dokument verdrahtet, denn AcroForm und XFA hängen beide davon ab. Alle siebzehn Version-2-Slots werden zugewiesen und xfa_disabled genullt, nur wenn alle drei Fakten zutreffen. Nachdem die Umgebung existiert, läuft FPDF_LoadXFA nur für diesen Fall, und nur ein Rückgabewert ungleich 0 lässt TPdf.XfaRuntimeAvailable True melden. FPDF_LoadXFA wird auch von Builds ohne XFA-Engine exportiert und liefert dort schlicht false, deshalb genügt die Export-Prüfung allein nicht
Ist das Dokument XFA, aber die Runtime fehlt, bleibt der Record bei Version 2 mit xfa_disabled auf 1 und einem NULL gelassenen Schwanz. Die Umgebung öffnet, der XFA-Inhalt bleibt statisch, und TPdf wirft OnXfaRuntimeMissing, damit der Host einen Neustart auf pdfium.v8.dll vorschlagen kann. m_pJsPlatform folgt einer dritten Regel: Es wird angehängt, sobald V8FeaturesAvailable true ist, auch für schlichte AcroForm-Dateien, genau das lässt Dokument-Skripte, Feldberechnungen und app.alert unter dem V8-Build laufen. PageCount liest den statischen Seitenbaum, bis XFA das Dokument über FFI_PageEvent neu verteilt; ab dann wird die neue Gesamtzahl aus FPDF_GetPageCount genommen und über OnXfaPageCountChanged gemeldet
Host-seitig beantworten zwei Properties zwei verschiedene Fragen. XFA sagt, was die Datei enthält, aus FPDF_GetFormType, ganz gleich welche Binary. XfaRuntimeAvailable sagt, ob die Engine für dieses Dokument tatsächlich gestartet ist. Die zweite nach Active lesen, so wie es der Artikel zum Erkennen von XFA-Formularen und Extrahieren von XFA-Paketen tut, und das Versionsfeld komplett aus der Überlegung lassen
procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
// Wird beim Öffnen des Dokuments geworfen, wenn es XFA ist, die geladene DLL
// die Engine aber nicht laufen lassen kann. Die Form-Umgebung existiert trotzdem, mit XFA deaktiviert
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; // schlichte AcroForm-Dateien öffnen auch unter pdfium.v8.dll
if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
ShowStaticXfaNotice; // eigene UI-Routine
end;
Layout-Fehler, die den Record kaputt machen, bevor die Version gelesen wird
Eine korrekte Versionsnummer hilft nur, wenn die Bytes hinter ihr dort sitzen, wo der C-Compiler sie hingelegt hat. Mehrere Binding-Fehler produzieren einen Record, den PDFium falsch liest, egal welche Version er trägt, und einer davon bleibt auf Win32 unsichtbar: FPDF_FORMFILLINFO als packed record zu deklarieren. Auf Win32 ist jedes Feld vier Bytes groß, gepacktes und ausgerichtetes Layout fallen also zufällig zusammen. Auf Win64 polstert die C-Struct vier Bytes nach version, damit Release auf einer 8-Byte-Grenze landet, und polstert erneut nach xfa_disabled; eine gepackte Pascal-Deklaration lässt beide Lücken weg und verschiebt jeden Callback-Zeiger um vier Bytes. Die Deklaration in FPdfFormFill.pas ist aus diesem Grund ein schlichtes record
Der zweite Fehler ist ein abgeschnittener Record: Eine Übersetzung, die bei m_pJsPlatform aufhört, aber Version 2 schreibt. PDFium liest den Schwanz dann aus dem Speicher, der zufällig hinter dem Record liegt. Der dritte Fehler ist die Lebensdauer. Der Header sagt, der Record, der an FPDFDOC_InitFormFillEnvironment übergeben wird, muss gültig bleiben, bis das zurückgegebene Form-Handle geschlossen wird, ein Record in einer lokalen Variable der Prozedur, die das Dokument öffnet, ist also in dem Moment ein hängender Zeiger, in dem die Prozedur zurückkehrt. TPdf hält seinen Record als Feld der Komponente, verpackt in einen äußeren Record, der die PDFium-Struct bei Offset 0 und direkt dahinter einen Back-Pointer auf den besitzenden TPdf platziert, genau so finden alle statischen Callbacks ihr Objekt wieder
type
// Falsch auf Win64: Packing entfernt das Padding, das die C-Struct nach
// version hat, Release sitzt hier bei Offset 4 und in PDFium bei Offset 8
TBadFormFillInfo = packed record
version: Integer;
Release: Pointer;
// ... übrige Felder
end;
// Eigener Wrapper: zuerst der PDFium-Record, dahinter der eigene Back-Pointer
THostFormFillInfo = record
Info: FPDF_FORMFILLINFO; // Offset 0, pThis castet also direkt zurück
Owner: TObject;
end;
PHostFormFillInfo = ^THostFormFillInfo;
procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
left, top, right, bottom: Double); cdecl;
begin
try
// TMyViewer und InvalidatePdfRect stehen für die eigene Viewer-Klasse
TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
page, left, top, right, bottom);
except
// eine Pascal-Exception darf nicht durch PDFiums C++-Frames unwinden
end;
end;
Zwei weitere Slots verdienen Sorgfalt. FFI_GetLocalTime gibt ein 16-Byte-FPDF_SYSTEMTIME per Wert zurück, und Delphi und MSVC sind sich auf Win32 uneinig, wo der versteckte Ergebnis-Zeiger liegt, eine Pascal-Implementierung würde also den FPDF_FORMFILLINFO des Aufrufers korrumpieren. Aktuelles PDFium markiert das Member als unbenutzt, und die Pascal-Deklaration trägt einen Kommentar, es nil zu lassen; PDFium Component tut das. Jeder Callback, den die Komponente installiert, fängt Pascal-Exceptions und gibt stattdessen den dokumentierten Fehlerwert zurück, denn das C-ABI hat keine Möglichkeit, eine Exception über die Grenze zurückzutragen. Der Artikel zum Hardening des PDFium-ABI in Delphi geht diese Disziplin am Beispiel von FFI_OpenFile durch
Protokollversion und Feature-Verfügbarkeit sind zwei Achsen
Ein Versionsfeld in einer Callback-Struktur beantwortet, wie groß der Record ist und was daraus gelesen werden darf. Feature-Erkennung beantwortet, welche dieser Slots überhaupt etwas tun. Das Erste ist fixiert durch die native Binary und durch die Deklaration, gegen die man kompiliert hat; das Zweite variiert pro Dokument, pro DLL-Export-Tabelle und pro Host-Einstellung. Der XFA-Fall braucht beide Antworten gleichzeitig, was verlockt, sie in einen Boolean zu quetschen, und diese Quetschung bricht in dem Moment, in dem ein Build eine Mindestversion erzwingt. XFA-Formulare, die ISO 32000-1 §12.7.8 als XML-Payload beschreibt, die neben dem AcroForm-Dictionary mitläuft, sind hier das Feature. Das Record-Layout ist das Protokoll, und PDFium darf das Layout prüfen, bevor es die Datei liest
Dieselbe Struktur taucht überall auf, wo eine C-Bibliothek ihre Structs versioniert: FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, jede Callback-Tabelle mit einem Size- oder Version-Header. Das neueste Layout deklarieren, das man versteht, es vollständig leeren, die passende Version bedingungslos schreiben und die Slots nach dem befüllen, was Dokument und DLL tatsächlich können. Führt ein künftiges fpdf_formfill.h eine Version 3 ein, ist die Arbeit eine neue Deklaration und eine geänderte Zuweisung. Ein am Dokumentinhalt hängender Zweig müsste für jede Kombination aus Build und Datei neu durchdacht werden, und die, die er falsch bekommt, ist meist die, die niemand geöffnet hat
Das hier beschriebene Record-Handling ist Teil des PDFium Component für Delphi, Lazarus und C++Builder, mit derselben Deklaration für Win32- und Win64-Builds. Eine Anwendung, die bereits pdfium.v8.dll für JavaScript-getriebene AcroForms mitbringt, kann den Rest ihres Archivs, XFA oder nicht, durch diese eine Binary öffnen, ohne Sonderbehandlung der Form-Umgebung