Technisch artikel

FPDF_FORMFILLINFO versie 2 in Delphi: volg de DLL-ABI

Een AcroForm-factuur met een handvol tekstvelden en geen XFA-inhoud opent prima via een gewone pdfium.dll, toch kan hetzelfde bestand FPDFDOC_InitFormFillEnvironment NULL laten teruggeven zodra het proces de XFA-achtige pdfium.v8.dll draait. De bytes van het document zijn in beide runs identiek. Wat verschilt is één geheel getal: XFA-builds van PDFium eisen dat FPDF_FORMFILLINFO.version 2 is, en een binding die 1 schrijft voor documenten zonder XFA wordt afgewezen voordat PDFium ook maar één callback aankijkt

Waarom weigert FPDFDOC_InitFormFillEnvironment een gewone AcroForm-PDF?

De afwijzing komt uit de binary, en het document heeft er niets over te zeggen. De form-fill-header, fpdf_formfill.h in PDFium en zijn Pascal-vertaling in FPdfFormFill.pas, stelt dat een build die met de XFA-module is gecompileerd versie 2 vereist, en hij zegt dat twee keer in hetzelfde commentaarblok. Een build zonder XFA accepteert 1 of 2. Onder een binding die versie 1 stuurt voor niet-XFA-bestanden faalt precies één van de vier combinaties van binary en document: een versie-1-record samen met een gewoon AcroForm-bestand naar pdfium.v8.dll. De gewone DLL neemt dat record aan. De V8-DLL neemt een versie-2-record aan voor elk document. Een echte XFA-form op de V8-DLL krijgt van een binding die de versie aan het document koppelt nooit versie 1, dus de falende cel is precies de ene waar niemand aan test denkt

Een handgeschreven binding ziet FPDFDOC_InitFormFillEnvironment op dat punt NULL teruggeven en heeft niets verder om op voort te borduren. TPdf in PDFium Component zou een geweigerde omgeving als een EPdfError melden tijdens Active := True, maar hij schrijft altijd versie 2, dus die cel komt daar nooit voor. In die cel terechtkomen is makkelijk. Een host zet de globale EnableV8Engine op True omdat hij AcroForm-JavaScript wil, en vanaf dat moment gaat elke PDF in het proces door de V8-binary. Of de host raakt de vlag nooit aan, maar het eerste bestand dat hij opent heeft een /XFA-entry in zijn AcroForm-woordenboek of /NeedsRendering true in zijn catalogus: als de PDFium-library nog niet is geladen, scant TPdf het bestand op die twee markeringen en zet hij EnableV8Engine aan vóórdat de DLL wordt geladen. In beide gevallen geldt de keuze voor de rest van het proces, en elk gewoon AcroForm-bestand dat daarna wordt geopend treft de XFA-build. Welke binary u in de eerste plaats meestuurt, behandelt de gids over het uitrollen van de PDFium-DLL en het diagnosticeren van laadfouten; alles hieronder gaat ervan uit dat de V8-build al is geladen

PDFium Component-diagram van de vier combinaties van gewone pdfium.dll en XFA-achtige pdfium.v8.dll tegen AcroForm- en XFA-documenten: een versie-1-record faalt alleen op de V8-build met een gewoon formulier, in FPDFDOC_InitFormFillEnvironment, terwijl een versie-2-record alle vier opent
De ABI-versie aan het document koppelen betekent dat de procesbrede keuze van de V8-binary van elke latere gewone PDF een mislukte omgevingsinitialisatie maakt

Wat het versieveld in FPDF_FORMFILLINFO belooft

FPDF_FORMFILLINFO.version vertelt PDFium hoeveel van het record hij mag lezen. Versie 1 dekt het stabiele deel: Release, de callbacks van FFI_Invalidate tot FFI_DoGoToAction, en de pointer m_pJsPlatform. Versie 2 voegt een experimentele staart toe. Een build zonder XFA die versie 2 ontvangen heeft, roept ook de experimentele callbacks aan die hij daar aantreft; een build met XFA neemt niets minder aan. De header noemt het document helemaal niet. Met een 2 belooft u dat het geheugen na m_pJsPlatform bestaat en dat elke plek erin ofwel een geldige cdecl-functie ofwel NULL bevat

Die staart begint met xfa_disabled, een FPDF_BOOL die onder versie 2 wordt genegeerd, gevolgd door zeventien functiepointers van FFI_DisplayCaret tot FFI_DoURIActionWithKeyboardModifier. Vijftien ervan, FFI_DisplayCaret tot FFI_PutRequestURL, zijn gedocumenteerd als "Required for XFA, otherwise set to NULL". De laatste twee, FFI_OnFocusChange en FFI_DoURIActionWithKeyboardModifier, zijn voor elke host optioneel, en de header voegt eraan toe dat een versie-2-embedder die FFI_DoURIActionWithKeyboardModifier leeg laat, zijn URI-kliks laat omleiden naar FFI_DoURIAction. Samengevat: NULL is de voorgeschreven waarde voor een host die XFA niet aanstuurt. Een record dat met FillChar is gewist en met versie 2 is gestempeld, voldoet op een gewone build precies zo aan het contract als een versie-1-record, en het is bovendien het enige record dat een XFA-build accepteert

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

De JavaScript-kant volgt hetzelfde patroon. m_pJsPlatform wordt door builds zonder V8 niet gebruikt; op een V8-build stopt een NULL-pointer de JavaScript-uitvoering terwijl het document rendert. Het record IPDF_JsPlatform waar hij naar wijst heeft een eigen veld version, en de header zegt dat dat er momenteel 2 moet zijn

De valkuil: het document de ABI-versie laten kiezen

De fout is één voorwaardelijke die op zichzelf zinnig oogt. Een handgeschreven binding moet toch al beslissen of de XFA-callbacks het bekabelen waard zijn, en het ligt voor de hand om dezelfde test het versienummer te laten kiezen

// Een verleidelijke vorm voor een handgeschreven binding: de versie volgt het document
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;   // elk niet-XFA-document krijgt 1

FormHandle := FPDFDOC_InitFormFillEnvironment(Doc, FormInfo);
// op pdfium.v8.dll met een gewoon AcroForm-bestand is FormHandle hier nil

De bedoeling van die tak is defensief: de versie op 1 houden zodat een XFA-build nooit slots leest die de host niet heeft gevuld. Maar de FillChar op de regel erboven heeft ze al met NULL gevuld, wat de header als de juiste waarde definieert voor een host die geen XFA draait. De tak bewaakt een gevaar dat de declaratie al heeft weggenomen en loopt ruggestuik recht het gevaar in waarvoor de header waarschuwt. Bovendien is hij niet te vangen door XFA-bestanden te testen, want die nemen elke keer het versie-2-pad

PDFium Component bouwt het record precies andersom. Zodra TPdf.FormFill True is, wist het openen van een document het volledige record, schrijft versie 2 en xfa_disabled := 1, en pas daarna vraagt hij wat het document nodig heeft. Als regel voor elke binding: stel de versie in vanuit het record dat u hebt gedeclareerd, en laat niets anders eraan komen. Hier is die vorm voor uw eigen code, met een wrapper THostFormFillInfo die verderop wordt gedeclareerd

// FormInfo is een THostFormFillInfo-veld van uw viewerobject, dus hij overleeft
// de form-handle; HostInvalidate, HostGetPage en de rest zijn uw eigen
// cdecl-callbacks
FillChar(FormInfo, SizeOf(FormInfo), 0);
FormInfo.Owner := Self;

FormInfo.Info.version := 2;        // past bij de gedeclareerde layout, altijd
FormInfo.Info.xfa_disabled := 1;   // XFA blijft uit totdat bruikbaarheid is bewezen

FormInfo.Info.FFI_Invalidate := HostInvalidate;
FormInfo.Info.FFI_GetPage := HostGetPage;
FormInfo.Info.FFI_GetCurrentPage := HostGetCurrentPage;
// ... de andere versie-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;
  // ... de overige versie-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);

Waar RuntimeReady en V8FeaturesAvailable thuishoren

Capability-checks bepalen welke slots worden gevuld, en in PDFium Component raken ze de layout nooit aan. TPdf stelt gereedheid vast uit drie feiten: het document meldt via TPdf.XFA een XFA-formuliertype, de globale XfaFeaturesAvailable vindt de XFA-stringhelpers die de geladen DLL exporteert, en V8FeaturesAvailable vindt de V8-exports. De versie-1-callbacks worden voor elk document bekabeld, want AcroForm en XFA hangen er allebei van af. Alle zeventien versie-2-slots worden toegewezen, en xfa_disabled gewist, alleen als alle drie de feiten kloppen. Na het aanmaken van de omgeving draait FPDF_LoadXFA alleen in dat geval, en alleen een retour ongelijk nul laat TPdf.XfaRuntimeAvailable True melden. FPDF_LoadXFA wordt zelfs door builds zonder de XFA-engine geëxporteerd en geeft daar simpelweg false terug, wat de reden is dat de exportcontrole alleen niet genoeg is

Is het document XFA maar de runtime afwezig, dan blijft het record op versie 2 met xfa_disabled op 1 en de staart op NULL. De omgeving opent, de XFA-inhoud blijft statisch, en TPdf roept OnXfaRuntimeMissing aan zodat de host kan voorstellen om op pdfium.v8.dll te herstarten. m_pJsPlatform volgt een derde regel: hij wordt gekoppeld zodra V8FeaturesAvailable true is, ook voor gewone AcroForm-bestanden, en dat is wat documentscripts, veldberekeningen en app.alert onder de V8-build laten draaien. PageCount leest de statische paginaboom tot XFA het document via FFI_PageEvent herpagineert; op dat moment wordt het nieuwe totaal uit FPDF_GetPageCount gehaald en via OnXfaPageCountChanged gemeld

Aan de hostkant beantwoorden twee eigenschappen twee verschillende vragen. XFA vertelt u wat het bestand bevat, uit FPDF_GetFormType, ongeacht de binary. XfaRuntimeAvailable vertelt u of de engine voor dit document werkelijk is gestart. Lees de tweede nadat Active True is geworden, zoals het artikel over het detecteren van XFA-formulieren en het extraheren van XFA-pakketten doet, en houd het versieveld volledig buiten de redenering

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Wordt aangeroepen terwijl het document opent wanneer het XFA is maar de geladen DLL
  // de engine niet kan draaien. De formulieromgeving bestaat nog, met XFA uitgeschakeld
  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;   // gewone AcroForm-bestanden openen ook onder pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaNotice;   // uw eigen UI-routine
end;

Layoutfouten die het record breken voordat de versie wordt gelezen

Een correct versienummer helpt alleen als de bytes erachter zitten waar de C-compiler ze heeft neergezet. Verschillende bindfouten leveren een record op dat PDFium verkeerd leest, welke versie het ook draagt, en één daarvan blijft op Win32 onzichtbaar. Dat is FPDF_FORMFILLINFO als packed record declareren. Op Win32 is elk veld vier bytes, dus packed en aligned layouts vallen toevallig samen. Op Win64 vult de C-struct na version vier bytes op zodat Release op een 8-byte-grens landt, en vult na xfa_disabled opnieuw; een packed Pascal-declaratie laat beide gaten weg en schuift elke callbackpointer vier bytes op. De declaratie in FPdfFormFill.pas is daarom een kale record

De tweede fout is een afgekapt record: een vertaling die bij m_pJsPlatform stopt maar versie 2 schrijft. PDFium leest de staart dan uit welk geheugen erop volgt. De derde is de levensduur. De header zegt dat het record dat aan FPDFDOC_InitFormFillEnvironment wordt doorgegeven geldig moet blijven tot de teruggegeven form-handle wordt gesloten, dus een record in een lokale variabele van de procedure die het document opent is een bungelende pointer op het moment dat die procedure terugkeert. TPdf houdt zijn record als veld van de component bij, verpakt in een buitenrecord dat de PDFium-struct op offset nul zet en een terugverwijzing naar de bezittende TPdf er direct achter, en zo vindt elke statische callback zijn object terug

type
  // Fout op Win64: packing verwijdert de padding die de C-struct na version heeft,
  // dus Release zit hier op offset 4 en in PDFium op offset 8
  TBadFormFillInfo = packed record
    version: Integer;
    Release: Pointer;
    // ... overige velden
  end;

  // Uw eigen wrapper: eerst het PDFium-record, daarna uw terugverwijzing
  THostFormFillInfo = record
    Info: FPDF_FORMFILLINFO;   // offset 0, dus pThis cast direct terug
    Owner: TObject;
  end;
  PHostFormFillInfo = ^THostFormFillInfo;

procedure HostInvalidate(pThis: PFPDF_FORMFILLINFO; page: FPDF_PAGE;
  left, top, right, bottom: Double); cdecl;
begin
  try
    // TMyViewer en InvalidatePdfRect staan voor uw eigen viewerklasse
    TMyViewer(PHostFormFillInfo(pThis).Owner).InvalidatePdfRect(
      page, left, top, right, bottom);
  except
    // een Pascal-exception mag niet door de C++-frames van PDFium ontvouwen
  end;
end;

Twee slots verdienen extra zorg. FFI_GetLocalTime geeft een 16-byte FPDF_SYSTEMTIME per waarde terug, en Delphi en MSVC zijn het op Win32 oneens over waar de verborgen resultaatpointer terechtkomt, dus een Pascal-implementatie zou de FPDF_FORMFILLINFO van de aanroeper corrupt maken. De huidige PDFium markeert het lid als ongebruikt, en de Pascal-declaratie draagt een commentaar om het op nil te laten; PDFium Component doet dat. Elke callback die de component wél installeert vangt Pascal-exceptions op en geeft in plaats daarvan de gedocumenteerde faalwaarde terug, want de C-ABI heeft geen manier om een exception terug over de grens te dragen. De notitie over het harden van de PDFium-ABI in Delphi loopt die discipline langs met FFI_OpenFile als voorbeeld

Protocolversie en featurebeschikbaarheid zijn twee assen

Een versieveld in een callbackstructuur beantwoordt hoe groot het record is en wat eruit mag worden gelezen. Featuredetectie beantwoordt welke van die slots iets zullen doen. Het eerste ligt vast in de native binary en in de declaratie waartegen u hebt gecompileerd; het tweede verschilt per document, per DLL-exporttabel en per hostinstelling. Het XFA-geval heeft beide antwoorden tegelijk nodig, wat het verleidelijk maakt ze in één boolean te vouwen, en dat vouwen breekt op het moment dat een build een minimumversie afdwingt. XFA-formulieren, die ISO 32000-1 §12.7.8 beschrijft als een XML-payload die naast het AcroForm-woordenboek reist, zijn hier de feature. De recordlayout is het protocol, en PDFium heeft het recht de layout te controleren voordat hij het bestand leest

PDFium Component-diagram 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, de zeventien versie-2-slots en FPDF_LoadXFA begrenst terwijl V8FeaturesAvailable m_pJsPlatform bepaalt, per document en per host
Een versieveld beschrijft het geheugen dat de andere kant mag lezen, capability-checks bepalen welke slots iets nuttigs doen, en de twee in één boolean vouwen breekt de build die een minimum afdwingt

Dezelfde structuur duikt overal op waar een C-library zijn structs versioneert: FPDF_LIBRARY_CONFIG, IPDF_JsPlatform, elke callbacktabel met een size- of versiekop. Declareer de nieuwste layout die u begrijpt, wis hem volledig, schrijf de bijpassende versie onvoorwaardelijk, en vul slots naar gelang wat het document en de DLL werkelijk kunnen. Voert een toekomstige fpdf_formfill.h versie 3 in, dan is het werk een nieuwe declaratie en één gewijzigde toewijzing. Een tak die op documentinhoud sleutelt, zou voor elke combinatie van build en bestand opnieuw moeten worden bekeken, en de combinatie die hem misgaat is meestal degene die niemand ooit opende

De recordafhandeling die hier wordt beschreven maakt deel uit van PDFium Component voor Delphi, Lazarus en C++Builder, met dezelfde declaratie voor Win32- en Win64-builds. Een applicatie die al pdfium.v8.dll meestuurt voor JavaScript-gedreven AcroForms, kan de rest van zijn archief, XFA of niet, door die ene binary openen zonder speciale afhandeling voor de formulieromgeving