Article technique

FPDF_FORMFILLINFO version 2 en Delphi : suivre l'ABI

PDFium Component met désormais FPDF_FORMFILLINFO.version à 2 pour chaque environnement de remplissage de formulaire qu'il initialise, parce que la version qu'une compilation native de PDFium accepte est une propriété de cette compilation, pas du document qu'on ouvre. Un pdfium.v8.dll prenant en charge XFA refuse catégoriquement la version 1, donc un simple PDF AcroForm ouvert à travers lui échouait dans FPDFDOC_InitFormFillEnvironment sans le moindre XFA en vue. Le correctif de la v3.116.0 est petit, mais l'erreur derrière est générale et mérite d'être nommée : un champ de version de protocole décrit la disposition mémoire que l'autre côté attend, et il ne doit jamais être déduit du fait que vous avez ou non besoin des fonctionnalités que cette disposition transporte

Pourquoi FPDFDOC_InitFormFillEnvironment échoue-t-il sur un PDF simple avec pdfium.v8.dll ?

L'environnement échoue parce qu'une compilation PDFium prenant en charge XFA valide le champ version avant de faire quoi que ce soit d'autre, et l'ancienne logique de l'enveloppe lui passait un 1 chaque fois que le document courant n'était pas un formulaire XFA. Le symptôme dans un hôte Delphi est une EPdfError levée depuis TPdf.InitializeFormFill avec le message Cannot initialize form fill environment, jetée à l'ouverture d'une facture ou d'un formulaire fiscal ordinaire qui ne contient que des champs texte AcroForm. Le même fichier s'ouvre sans problème contre le pdfium.dll simple. La même DLL ouvre sans problème un vrai document XFA. Seule la combinaison de la compilation V8 et d'un document non XFA casse, et c'est exactement la combinaison dans laquelle un hôte atterrit après avoir activé EnableV8Engine pour obtenir le JavaScript AcroForm, ou après que la sélection automatique dans LoadDocument a déjà engagé le processus sur pdfium.v8.dll à cause d'un fichier XFA antérieur. Cet engagement est à l'échelle du processus : EnableV8Engine est lu avant le premier LoadLibrary, et une fois la compilation XFA chargée, chaque PDF simple ultérieur passe par la même configuration d'environnement contre le même binaire. L'hôte n'a rien fait de mal ; c'est l'enveloppe qui a posé la mauvaise question en remplissant l'enregistrement. Si vous êtes encore en train de décider quel binaire livrer, notre note sur le déploiement de la DLL PDFium et le diagnostic des échecs de chargement couvre le choix entre binaire simple et V8, et cet article suppose que la compilation V8 est déjà dans le processus

Schéma PDFium Component des quatre combinaisons entre pdfium.dll simple et pdfium.v8.dll avec XFA face à des documents AcroForm et XFA : un enregistrement en version 1 ne cassait que la compilation V8 avec un formulaire simple, EPdfError dans FPDFDOC_InitFormFillEnvironment, tandis que l'enregistrement corrigé en version 2 ouvre les quatre
Un seul conditionnel liait la version de l'ABI au document, donc le choix à l'échelle du processus du binaire V8 transformait chaque PDF simple ultérieur en initialisation d'environnement ratée

Que promet réellement le champ version de FPDF_FORMFILLINFO ?

FPDF_FORMFILLINFO.version indique à PDFium quels champs de l'enregistrement il a le droit de lire, et l'en-tête public fpdf_formfill.h lie les valeurs acceptables à la façon dont la bibliothèque a été compilée plutôt qu'au document. En paraphrase, le contrat a trois parties. La version 1 couvre les rappels stables de FFI_Invalidate à FFI_DoGoToAction plus le pointeur m_pJsPlatform. Une compilation sans le module XFA accepte 1 ou 2, et avec 2 elle appellera en plus les rappels expérimentaux supplémentaires. Une compilation avec le module XFA exige 2, point final, et l'en-tête répète cette exigence deux fois comme s'il s'attendait à ce qu'on la manque. Nulle part le contrat ne mentionne le document. La version est une déclaration sur l'enregistrement que vous avez alloué : avec un 2, vous promettez que la mémoire après m_pJsPlatform existe et contient soit des pointeurs de fonction valides, soit NULL

La région de la version 2 est là où vit toute la machinerie XFA. Elle commence par xfa_disabled, un FPDF_BOOL que l'en-tête décrit comme ignoré en dessous de la version 2 et significatif seulement quand le module XFA est compilé dedans, et se poursuit par dix-sept pointeurs de fonction, de FFI_DisplayCaret à FFI_DoURIActionWithKeyboardModifier. Chacun d'eux est documenté comme obligatoire pour XFA et sinon à mettre à NULL. Cette formulation est la clé de tout le correctif. NULL n'est pas un état d'erreur pour ces emplacements ; c'est l'état documenté pour un hôte qui ne pilote pas XFA. Un enregistrement nettoyé avec FillChar puis marqué comme version 2 satisfait le contrat sur une compilation non XFA exactement aussi bien qu'un enregistrement en version 1, et c'est le seul enregistrement qu'une compilation XFA acceptera

Schéma PDFium Component de l'enregistrement FPDF_FORMFILLINFO en Delphi : la version 1 couvre les rappels FFI_Invalidate à FFI_DoGoToAction plus m_pJsPlatform, la version 2 ajoute xfa_disabled et dix-sept pointeurs de l'ère FFI_DisplayCaret, FillChar remet chaque octet à zéro, et les emplacements NULL sont l'état documenté pour un hôte qui ne pilote pas XFA
L'enregistrement Pascal est toujours la disposition complète de la version 2, donc une compilation avec XFA l'accepte et une compilation simple n'appelle simplement jamais les emplacements expérimentaux restés NULL

L'ancienne sélection liait l'ABI au document

Le défaut était un unique conditionnel qui semblait raisonnable pris isolément. TPdf.InitializeFormFill calcule un drapeau RuntimeReady à partir de trois faits : le document signale un type de formulaire XFA via TPdf.XFA, les utilitaires de chaîne XFA se sont résolus via XfaFeaturesAvailable, et les exports V8 se sont résolus via V8FeaturesAvailable. Avant la v3.116.0, ce même drapeau choisissait aussi la version

// v3.115.0 et antérieur : la version de l'ABI suivait le document
RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;

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

// ... et la branche runtime manquant le fixait de nouveau
else if XFA then
begin
  FFormFillInfo.Info.version := 1;
  FFormFillInfo.Info.xfa_disabled := 1;
  if Assigned(FOnXfaRuntimeMissing) then
    FOnXfaRuntimeMissing(Self);
end;

Relisez-le avec l'en-tête sous les yeux et l'échec est évident. RuntimeReady est faux pour tout document AcroForm simple, donc tout document simple annonçait la version 1. Sur pdfium.dll, cela va bien. Sur pdfium.v8.dll, qui est la compilation prenant en charge XFA, PDFium vérifie le champ, le trouve en dessous du 2 exigé, et renvoie un FPDF_FORMHANDLE nul, que CheckPdf transforme en l'exception ci-dessus. L'intention de l'ancien code était défensive : garder la version 1 pour qu'une compilation XFA ne lise jamais les emplacements de version 2 non affectés. Il se défendait contre un problème que l'en-tête écarte déjà et en créait un que l'en-tête signale explicitement. Le code corrigé décide la version une fois, d'entrée, à partir de ce que l'enregistrement est physiquement

procedure TPdf.InitializeFormFill;
var
  RuntimeReady: Boolean;
begin
  FXfaRuntimeUsable := False;
  FXfaPageCountOverride := -1;   // sentinelle : utiliser l'arbre de pages statique
  if not FormFill then
    Exit;

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

  // L'enregistrement complet en version 2 est alloué et nettoyé ci-dessus. PDFium
  // accepte la version 2 sans XFA et l'exige dans toute compilation
  // avec XFA, y compris quand ce document ne contient aucun formulaire XFA.
  FFormFillInfo.Info.version := 2;
  FFormFillInfo.Info.xfa_disabled := 1;

  // RuntimeReady filtre les rappels XFA et xfa_disabled, jamais la version.
  RuntimeReady := XFA and XfaFeaturesAvailable and V8FeaturesAvailable;
  ...

Où RuntimeReady a encore sa place : les rappels et xfa_disabled

RuntimeReady garde son rôle de filtre pour le comportement XFA ; il ne touche simplement plus à la disposition de l'enregistrement. Les rappels de version 1, FFI_Invalidate, FFI_SetTimer, FFI_GetPage, FFI_DoURIAction, FFI_DoGoToAction et le reste de ce bloc, sont câblés inconditionnellement parce qu'AcroForm et XFA en dépendent tous les deux. Les dix-sept pointeurs de version 2 ne sont affectés qu'à l'intérieur de la branche RuntimeReady, avec xfa_disabled := 0. Quand le document est XFA mais que le runtime n'est pas là, l'enregistrement reste en version 2 avec xfa_disabled à 1 et les emplacements de version 2 laissés à NULL, et l'enveloppe déclenche OnXfaRuntimeMissing pour que l'hôte puisse suggérer de redémarrer sur pdfium.v8.dll. Une fois l'environnement existant, FPDF_LoadXFA n'est appelé que si RuntimeReady était vrai, et seul un retour vrai positionne FXfaRuntimeUsable, ce que rapporte TPdf.XfaRuntimeAvailable

  if RuntimeReady then
  begin
    FFormFillInfo.Info.xfa_disabled := 0;   // 0 = XFA activé
    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 jusqu'à FFI_DoURIActionWithKeyboardModifier
  end
  else if XFA then
  begin
    // Runtime indisponible : garder la version 2, laisser XFA désactivé, prévenir l'hôte.
    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;

Deux détails de ce bloc sont faciles à rater quand on écrit sa propre liaison. FXfaPageCountOverride est remis à -1 comme sentinelle avant que quoi que ce soit d'autre n'arrive, donc PageCount retombe sur l'arbre de pages statique jusqu'à ce que FFI_PageEvent signale une repagination ; un zéro à cet endroit revendiquerait silencieusement un document vide. Et chacun des rappels de version 2 est une routine statique cdecl qui récupère le TPdf propriétaire depuis l'enregistrement et avale toute exception Pascal avant de revenir à PDFium, ce qui est la discipline que notre note sur le durcissement de l'ABI PDFium en Delphi détaille pour FFI_OpenFile. Rien dans le changement de version n'assouplit l'une ou l'autre règle

La version 2 est-elle sûre quand la DLL n'a pas de module XFA ?

Oui, et la raison est dans l'enregistrement, pas dans une promesse de la bibliothèque. Sur une compilation non XFA, l'en-tête dit que la version 2 fait aussi appeler les rappels expérimentaux, donc la question est ce que PDFium trouve quand il regarde. TPdfFormFillInfo est un enregistrement compact dont le membre Info est le FPDF_FORMFILLINFO complet, y compris chaque champ de version 2, et InitializeFormFill nettoie tout l'ensemble avec FillChar avant de toucher un octet. Donc sur un pdfium.dll simple avec un document simple, la bibliothèque voit la version 2, xfa_disabled positionné, et NULL dans chaque emplacement expérimental, ce qui est précisément l'état que l'en-tête prescrit pour un hôte qui n'implémente pas XFA. Il n'y a pas d'enregistrement tronqué que la bibliothèque pourrait lire au-delà, parce que l'enregistrement n'a jamais été plus court que la version 2. L'ancienne logique défendait contre une inadéquation de disposition que la déclaration Pascal avait déjà éliminée

La limite qui mérite d'être énoncée honnêtement est celle que l'enregistrement ne peut pas couvrir. La version 2 sur un document simple n'active ni le JavaScript, ni le scripting XFA, ni aucun des événements hôte derrière ces rappels. m_pJsPlatform n'est attaché que lorsque V8FeaturesAvailable est vrai, XFA reste désactivé sauf si RuntimeReady était vrai, et TPdf.XFA continue à rapporter le type de formulaire depuis FPDF_GetFormType quel que soit ce que l'environnement a négocié. Un hôte qui veut savoir si le XFA dynamique va réellement s'afficher devrait continuer à lire XfaRuntimeAvailable après que Active est passé à vrai, comme le recommande notre note sur la détection des formulaires XFA et l'extraction des paquets XFA, plutôt que d'inférer quoi que ce soit du champ version

procedure TMainForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  // Se déclenche depuis InitializeFormFill quand le document est XFA mais que le
  // pdfium.dll chargé ne peut pas exécuter le moteur. L'environnement de formulaire
  // s'ouvre quand même, la version 2 ayant été passée dans les deux cas ; seul XFA est hors service.
  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;   // ne lève plus d'exception sur un PDF simple sous pdfium.v8.dll
  if Pdf.XFA and not Pdf.XfaRuntimeAvailable then
    ShowStaticXfaWarning;
end;

Version de protocole et disponibilité des fonctionnalités sont deux axes différents

La règle générale qui découle de ce correctif est qu'un champ de version dans une structure de rappels répond à la question « quelle taille fait cet enregistrement et que peut-on y lire », tandis que la détection de fonctionnalités répond à « lesquels de ces emplacements feront quelque chose d'utile ». La première est fixée par le binaire natif et par la déclaration Pascal contre laquelle vous avez compilé. La seconde varie selon le document, selon la table d'exports de la DLL et selon la configuration de l'hôte. Écraser les deux en un seul booléen est tentant parce que le cas XFA a justement besoin des deux, mais dès qu'une compilation impose une version minimale, l'écrasement casse pour tout document qui n'a pas besoin de la fonctionnalité. Les formulaires XFA, décrits dans ISO 32000-1 §12.7.8 comme une charge XML vivant à côté du dictionnaire AcroForm, sont la fonctionnalité ici ; la disposition de l'enregistrement est le protocole, et PDFium est en droit d'exiger la disposition avant même de regarder le fichier. La même forme apparaît partout où une bibliothèque C versionne ses structures : un bloc viewer-info, un enregistrement render-options, une table de rappels de plateforme. Le schéma sûr est celui que suit le InitializeFormFill corrigé. Déclarez la disposition la plus récente que vous comprenez, nettoyez-la complètement, mettez la version en accord avec cette disposition sans condition, puis laissez les vérifications de capacité décider quels emplacements remplir. Si un futur en-tête PDFium ajoute une version 3, le changement porte sur la déclaration et sur cette unique affectation, pas sur une branche dépendante du document qui sera fausse pour la combinaison que personne n'a testée

Schéma PDFium Component séparant les deux axes derrière FPDF_FORMFILLINFO : la version de protocole fixée par la disposition de l'enregistrement et le binaire natif, et la disponibilité des fonctionnalités où RuntimeReady filtre xfa_disabled, dix-sept emplacements de version 2, FPDF_LoadXFA et m_pJsPlatform selon le document et l'hôte
Un champ de version décrit la mémoire que l'autre côté peut lire, les vérifications de capacité décident quels emplacements font quelque chose d'utile, et écraser les deux en un booléen casse la compilation qui impose un minimum

L'initialisation de remplissage de formulaire corrigée est livrée dans PDFium Component pour Delphi, Lazarus et C++Builder, et elle s'applique aussi bien en Win32 qu'en Win64 puisque les deux compilations partagent la même déclaration d'enregistrement. Si votre application sélectionne déjà pdfium.v8.dll pour les AcroForm pilotés par JavaScript, c'est le changement qui lui permet d'ouvrir le reste de votre archive PDF via le même binaire sans traiter l'environnement de formulaire comme un cas particulier