Article technique

XFA dynamique PDFium : le nombre de pages est un delta

Quand un formulaire XFA dynamique dans un visualiseur Delphi ajoute ou retire des pages, PDFium Component signale le nouveau total via TPdf.PageCount et TPdf.OnXfaPageCountChanged depuis v3.126.1, parce que l’événement de page natif porte un delta ajouté/retiré plutôt qu’un total. Les bibliothèques Windows V8 de v3.126.1 déplacent aussi les zones de frappe avec les champs relocalisés, et v3.126.2 recharge les handles de page périmés après le retour du callback de layout. Le rapport de bug qui a déclenché tout cela, c’était un formulaire de note de frais : cliquez deux fois sur Ajouter une ligne, le formulaire grandit jusqu’à deux pages, et l’indicateur de page affiche fièrement 1 sur 1. Tapez dans un champ parti en page 2 et les frappes atterrissent quelque part d’invisible. Rien de tout cela n’apparaissait avec les formulaires d’exemple à longueur fixe que tout le monde teste en premier, et les raisons valent la peine d’être connues si vous embarquez un visualiseur de formulaires

Que se passe-t-il quand un formulaire XFA dynamique se repagine ?

Un formulaire XFA dynamique n’a pas de liste de pages fixe, donc son nombre de pages est une sortie du layout et peut changer à chaque édition de données par l’utilisateur. XFA 3.3 décrit le formulaire comme un arbre de subforms ; un subform répété est piloté par un instanceManager, et un script tel que _Row.addInstance() clone une ligne de plus. Le processeur de layout refait alors couler le contenu dans les zones de page, ce qui peut ajouter une page, retirer une page ou pousser des champs existants sur une autre page. ISO 32000-1 §12.7.8 ne définit que la façon dont les paquets XFA voyagent dans le PDF ; tout ce qui se passe ensuite appartient au moteur XFA, qui dans PDFium Component est le layout XFA de PDFium lui-même tournant dans le processus hôte. Un visualiseur Delphi a donc affaire à un document dont le nombre de pages, les tailles de page et les positions de widgets sont tous de l’état vivant. Trois choses tournent mal quand l’hôte suppose le contraire :

  • Le nombre de pages que l’hôte met en cache pour la navigation, les plages de défilement et les spinners de page se périme, ou pire, se met à jour avec le mauvais nombre
  • Les champs relocalisés montrent leur bordure à la nouvelle position pendant que l’éditeur et la zone de frappe souris restent aux anciennes coordonnées
  • Le visualiseur garde un handle de page que le layout a remplacé, donc les clics et les dessins vont vers une page qui n’existe plus dans ce formulaire

Persister les éditions de lignes à travers sauvegarde et réouverture est un problème séparé avec ses propres règles ; cet article reste sur ce qui se passe à l’exécution à l’intérieur du visualiseur

Quel runtime PDFium le XFA dynamique exige-t-il ?

Le XFA dynamique dans PDFium Component exige le build V8/XFA de la bibliothèque native, sélectionné par la variable globale EnableV8Engine de l’unité PDFium avant le chargement du premier document. Le processus s’engage sur une seule DLL la première fois qu’un TPdf charge la bibliothèque, et un build PDFium ordinaire ne peut pas du tout exécuter le moteur XFA. À l’ouverture d’un document, TPdf jette bien un œil au fichier à la recherche de marqueurs XFA et bascule automatiquement sur le build V8, mais seulement si aucune bibliothèque ordinaire n’a encore été chargée dans ce processus. Quand l’engagement est déjà parti du mauvais côté, TPdf.OnXfaRuntimeMissing se déclenche une fois pour que l’hôte puisse dire à l’utilisateur de redémarrer. Régler le drapeau explicitement au démarrage supprime le jeu de devinettes. La structure de callbacks FPDF_FORMFILLINFO qui porte les événements XFA doit aussi correspondre à la DLL ; le fond est dans FPDF_FORMFILLINFO version 2 et l’ABI des callbacks XFA, et la détection des formulaires XFA et la lecture de leurs paquets couvre la distinction des types de formulaires avant d’ouvrir un visualiseur

uses
  PDFium;

procedure TClaimForm.FormCreate(Sender: TObject);
begin
  // Décidez avant que le premier TPdf ne charge la bibliothèque native :
  // le processus ne peut pas passer de pdfium.dll à pdfium.v8.dll ensuite
  EnableV8Engine := True;

  FPdf := TPdf.Create(nil);
  FPdf.OnXfaRuntimeMissing := PdfXfaRuntimeMissing;
  FPdf.OnXfaPageCountChanged := PdfXfaPageCountChanged;
  FPdf.FileName := 'C:\Forms\expense-claim.pdf';
  FPdf.Active := True;

  PdfView1.Pdf := FPdf;
  PdfView1.OnPageChange := PdfViewPageChange;
  PdfView1.Active := True;

  UpdatePageRange(FPdf.PageCount);
end;

procedure TClaimForm.PdfXfaRuntimeMissing(Sender: TObject);
begin
  StatusBar1.SimpleText :=
    'This XFA form needs the V8 runtime; restart the application to enable it';
end;

Pourquoi PageCount annonçait-il 1 pour un formulaire de deux pages ?

Avant v3.126.1, PDFium Component stockait l’argument page_count de l’événement de page natif comme total du document, et cet argument est en réalité la différence absolue entre les nouveaux et les anciens nombres de pages. PDFium lève FFI_PageEvent après la fin d’une passe de layout avec un type d’événement page ajoutée ou page retirée ; en interne il met d’abord à jour son nombre de pages stocké puis passe abs(new - old). Au layout initial, l’ancien nombre vaut zéro, donc le delta égale le total, et un échantillon statique de trois pages annonce trois pages comme attendu. C’est exactement pourquoi les formulaires de test à longueur fixe n’ont jamais exposé le bug. La première fois qu’un formulaire dynamique grandit d’une page vers deux, le delta vaut 1, et le wrapper mettait à la fois TPdf.PageCount et le paramètre NewCount de OnXfaPageCountChanged à 1. Retirer une ligne d’un formulaire de trois pages produisait le même genre d’absurdité dans l’autre sens

Cumuler le delta sur la valeur précédente n’est pas non plus une réparation sûre. L’ordre des callbacks d’initialisation et de layout fait que le wrapper ne peut pas toujours faire confiance à son nombre antérieur comme base, donc une somme courante peut dériver. Depuis v3.126.1, le callback ignore l’argument comme compte et appelle FPDF_GetPageCount sur le document, qui lit le total depuis le layout qui vient de s’achever. Il vide ensuite les scènes de page en cache, stocke ce total comme override du nombre de pages XFA derrière TPdf.PageCount, et ce n’est qu’ensuite qu’il lève OnXfaPageCountChanged. Au moment où votre handler tourne, NewCount et FPdf.PageCount sont d’accord

Schéma XFA dynamique PDFium Component où l’ajout d’une ligne repagine un formulaire d’une page vers deux pages et FFI_PageEvent passe abs(new - old) comme delta, si bien que l’ancien wrapper annonçait TPdf.PageCount 1 tandis que v3.126.1 lit FPDF_GetPageCount et annonce le bon total
L’événement de page natif rapporte un delta ajouté-ou-retiré, pas un total, donc v3.126.1 ignore l’argument et lit le layout achevé avant de lever OnXfaPageCountChanged
procedure TClaimForm.PdfXfaPageCountChanged(Sender: TObject; NewCount: Integer);
begin
  // v3.126.1+ : NewCount est le total du layout achevé, jamais un delta.
  // Ceci tourne dans le callback de layout de PDFium : mettez à jour seulement l’état d’UI hôte,
  // ne fermez pas le document ni ne rechargez de pages depuis ici
  UpdatePageRange(NewCount);
end;

procedure TClaimForm.PdfViewPageChange(Sender: TObject);
begin
  // Se déclenche après chaque rechargement de page, y compris le rafraîchissement XFA différé
  PageSpin.Value := PdfView1.PageNumber;
end;

procedure TClaimForm.UpdatePageRange(Count: Integer);
begin
  PageSpin.MinValue := 1;
  PageSpin.MaxValue := Count;
  PageLabel.Caption := Format('of %d', [Count]);
end;

L’événement ne se déclenche que pour les formulaires Full XFA dont le layout change à l’exécution. Les documents Static XFA et AcroForm ne le lèvent jamais, donc un visualiseur qui gère les deux peut garder le même handler affecté. Le laisser non affecté est sûr aussi ; l’override derrière TPdf.PageCount s’applique de toute façon, et l’événement existe pour que l’hôte puisse rafraîchir ce qu’il a mis en cache

Pourquoi la zone de saisie reste-t-elle sur l’ancienne page quand un champ bouge ?

La bordure a bougé et l’éditeur non, parce que le notificateur XFA natif comparait un rectangle avec lui-même. Quand le layout change la géométrie d’un widget déjà chargé, PDFium est censé remarquer le nouveau rectangle et appeler PerformLayout sur le widget, ce qui repositionne l’éditeur de texte et sa zone de frappe. La vérification comparait GetWidgetRect() à RecacheWidgetRect(). Les deux fonctions renvoient une référence const vers le même membre, et le recache écrase ce membre sur place, si bien que la comparaison voyait toujours deux valeurs identiques et les widgets chargés sautaient leur relayout

Le symptôme est apparu quand un test changeait la hauteur d’un subform de façon à faire passer des champs existants sur la page suivante. Sur les deux architectures V8, la bordure du champ était dessinée à sa nouvelle position pendant que le texte tapé et la zone de frappe souris restaient à l’ordonnée Y précédente. Un relayout explicite ne réparait pas, et recharger la page non plus, parce que le widget croyait toujours sa géométrie à jour. Les bibliothèques Windows V8 livrées avec v3.126.1 copient l’ancien rectangle par valeur avant le recache et comparent cette copie, donc les widgets déplacés se relayoutent et la valeur éditée apparaît exactement là où est la bordure. C’est un correctif natif : il voyage avec les DLL, donc mettre à jour les unités Pascal en gardant une pdfium.v8.dll plus ancienne laisse les zones de frappe mal placées en l’état. Le test de régression qui l’a motivé édite d’abord une ligne survivante vers une valeur non par défaut puis exige cette valeur à l’emplacement nouveau du champ, parce qu’une ligne reconstruite avec les valeurs par défaut aurait sinon l’air d’un succès

Schéma de relayout de widget PDFium Component contrastant l’ancienne auto-comparaison où GetWidgetRect et RecacheWidgetRect renvoyaient un membre partagé unique si bien que les widgets déplacés sautaient PerformLayout, avec la vérification par copie de valeur v3.126.1 Windows V8 qui repositionne l’éditeur et la zone de frappe souris sur la bordure redessinée
Comparer un rectangle avec lui-même n’échoue jamais, donc la bordure bougeait pendant que le texte tapé et les clics restaient derrière, jusqu’à ce que la vérification sauve d’abord une copie par valeur

Comment TPdfView recharge-t-il les pages sans arracher un handle sous les pieds de PDFium ?

Depuis v3.126.2, TPdfView diffère le rechargement de page qui suit un changement de layout XFA jusqu’à ce que la pile d’appels native se soit déroulée. L’événement de page se déclenche habituellement pendant que PDFium traite encore la saisie : l’utilisateur a cliqué un bouton Ajouter une ligne, le clic a exécuté un script, le script a changé le compte d’instances, et le layout s’est achevé dans ce même appel natif. Fermer et rouvrir le handle de page à cet instant libérerait un objet que l’appelant utilise encore. Avant v3.126.2, le visualiseur s’invalidait seulement, si bien que le handle de page affiché pouvait continuer de pointer vers un état d’avant layout, et si l’utilisateur était sur la dernière page quand elle a disparu, le numéro de page sélectionné était hors plage

Le rafraîchissement différé marche en quelques petites étapes, et elles expliquent le comportement que vous observez depuis l’hôte :

  1. Le callback d’événement de page marque la vue comme ayant un rafraîchissement de layout XFA en attente et poste un message de fenêtre privé ; les événements répétés avant l’arrivée du message fusionnent en un seul rafraîchissement
  2. Une vue sans handle de fenêtre encore garde le drapeau en attente et poste le message depuis CreateWnd, tandis que changer de document, désactiver la vue ou la détruire efface le drapeau
  3. Quand le message arrive, la vue efface la sélection de texte, le surlignage de recherche et l’index du champ focalisé, parce que les trois se référaient à l’ancien layout
  4. La page sélectionnée est bornée au nouveau PageCount ; un numéro de page changé passe par la commutation de page normale, sinon la page courante est rechargée, et le mode d’ajustement est réappliqué
  5. Si le layout ne laisse aucune page du tout, la vue décharge son ancien handle de page au lieu de dessiner une page qui n’existe plus
Schéma du rafraîchissement XFA différé de TPdfView PDFium Component où un événement de page dans la pile d’appels native du layout se contente de marquer un rafraîchissement en attente et de poster un message de fenêtre, qui efface ensuite l’état de sélection périmé, borne la page au nouveau PageCount et recharge ou décharge le handle de page
Le rechargement attend que la pile d’appels native se déroule : un message posté fusionne les événements répétés, puis la vue borne la page, la recharge et lève OnPageChange

La même contrainte vaut pour votre propre code. OnXfaPageCountChanged tourne dans ce callback de layout natif, donc traitez-le comme une notification : mettez à jour libellés, plages de spinners et état de barre d’outils sur place, et mettez en file tout ce qui est plus lourd, comme fermer le document ou en ouvrir un autre, via un message posté pour que cela tourne après le retour du callback. TPdfView.OnPageChange vous dit alors quand la vue a réellement rechargé la page, et lire PdfView1.PageNumber à ce moment vous donne la valeur bornée. Le parcours par touche Tab et les vérifications FormType qu’un visualiseur de formulaires exécute à l’ouverture sont couverts dans la navigation dans les champs de formulaires PDF avec PDFium Component

Pourquoi cliquer un champ Full XFA lève-t-il « Cannot open text page » ?

Les pages Full XFA n’ont pas de page de texte PDF, et avant v3.126.2 la sélection de texte par défaut du visualiseur et la détection de liens tentaient quand même d’en charger une. Avec TPdfView.AllowUserTextSelection à son défaut True, le survol demandait à la couche de texte un caractère sous la souris, et un clic de relâchement lançait une sonde URL automatique sur le texte de la page. Sur une page Full XFA la page de texte ne peut pas être ouverte, donc un clic ordinaire dans un champ pouvait finir en exception Cannot open text page. Depuis v3.126.2, les deux chemins internes ne renvoient aucun résultat quand TPdf.FormType est ftXfaFull et que le runtime XFA est disponible, donc les réglages par défaut marchent et la saisie des champs reste disponible

Désactiver AllowUserTextSelection pour les documents Full XFA reste un choix d’UI raisonnable, parce qu’il n’y a pas de texte de page à sélectionner et que des gestes de glisser ne devraient pas démarrer un mode de sélection. Ce n’est pas un substitut à la mise à jour, toutefois : sur les versions antérieures la sonde URL au clic ne dépendait pas de cette propriété, donc un visualiseur pouvait toucher la même exception avec la sélection désactivée

procedure TClaimForm.ConfigureViewerForForm;
begin
  // FormType lit le document ouvert, appelez donc ceci après FPdf.Active := True
  if FPdf.XFA and (FPdf.FormType = ftXfaFull) and FPdf.XfaRuntimeAvailable then
  begin
    // Aucune couche de texte PDF n’existe sur les pages Full XFA ; les champs restent éditables
    PdfView1.AllowUserTextSelection := False;
    StatusBar1.SimpleText := Format('Dynamic XFA form, %d page(s)',
      [FPdf.PageCount]);
  end
  else
    PdfView1.AllowUserTextSelection := True;
end;

La frappe a eu besoin de sa propre réparation en v3.126.2. L’éditeur de texte XFA natif ne remplace pas une sélection quand il reçoit un caractère : FORM_OnChar insère au curseur, et Retour arrière supprime un seul caractère, donc sélectionner une valeur puis taper par-dessus produisait l’ancien et le nouveau texte côte à côte. PDFium Component se souvient désormais que le clic a atterri sur un champ texte XFA et route les caractères tapés, Retour arrière et Suppr via FORM_ReplaceSelection chaque fois qu’une sélection existe et que le document accorde la permission de remplir ou modifier. La question de savoir si un champ XFA en lecture seule peut changer reste tranchée par l’éditeur natif, donc un champ marqué lecture seule dans le formulaire garde sa valeur même dans un document qui autorise par ailleurs le remplissage. Mettre TPdfView.AllowFormEvents à False arrête aussi ce routage clavier, ce qui garde un visualiseur en lecture seule en lecture seule

Aide-mémoire : le XFA dynamique dans un visualiseur Delphi

SymptômeCauseCorrigé en
Le nombre de pages affiche 1 après que le formulaire a grandi à deux pagesL’événement de page natif passe un delta ajouté/retiré, pas un totalv3.126.1 (wrapper)
La bordure du champ bouge, le texte tapé et la zone de frappe restent derrièreLe widget chargé sautait son relayout après une auto-comparaisonv3.126.1 (bibliothèques Windows V8)
Le visualiseur dessine ou route la saisie vers un état de page d’avant layoutHandle de page non rechargé après repaginationv3.126.2 (rafraîchissement différé)
Cliquer dans un champ lève Cannot open text pageSélection de texte et sonde URL sur des pages sans couche de textev3.126.2
Taper par-dessus une valeur sélectionnée concatène au lieu de remplacerL’éditeur XFA natif insère au curseurv3.126.2
  • Réglez EnableV8Engine à True avant tout chargement de document, et traitez OnXfaRuntimeMissing pour le cas où la bibliothèque ordinaire a été chargée d’abord
  • Lisez le total depuis TPdf.PageCount ou le paramètre NewCount de OnXfaPageCountChanged ; n’ajoutez ni ne soustrayez jamais vous-même des nombres de pages
  • Gardez le handler OnXfaPageCountChanged léger, parce qu’il tourne dans le callback de layout natif
  • Synchronisez l’indicateur de page courante dans TPdfView.OnPageChange, qui se déclenche après que le rechargement différé a borné le numéro de page
  • Déployez les DLL Windows V8 v3.126.1 ou ultérieures avec les unités ; le correctif de relayout de widget vit dans le code natif
  • Testez avec un formulaire qui change réellement son nombre de pages et déplace un champ édité à travers un saut de page, parce que les échantillons à longueur fixe cachent chaque bug de cette liste

Le XFA dynamique transforme le nombre de pages et la géométrie des champs en valeurs vivantes, et un visualiseur ne reste correct que s’il les prend au layout achevé et recharge les pages à un moment sûr. PDFium Component gère les deux à l’intérieur de TPdf et TPdfView, si bien que l’hôte n’a qu’à écouter. Détails et téléchargements sont sur la page produit PDFium Component for Delphi