Article technique

Runtime XFA dynamique en Delphi : transactions HotPDF

HotPDF remplit les formulaires XFA dynamiques en Delphi à travers TXFAWidgetRuntime, une couche de widgets neutre quant à l’hôte qui traite chaque édition de champ comme une transaction : snapshot, validate, calculate, reflow, puis publication ou rollback complet. Elle tourne en monothread à l’intérieur de votre hôte VCL ou FMX, n’a besoin d’aucun Acrobat installé, et applique chaque budget avant d’allouer quoi que ce soit

Le scénario est familier à quiconque a livré un logiciel documentaire dans les métiers gouvernementaux ou d’assurance. Un formulaire de sinistre ou une déclaration fiscale arrive en PDF dont le contenu de page est un simple avis « Please wait... if this message is not eventually replaced », et chaque vrai champ vit dans un paquet XFA que seul Adobe Acrobat rend. Vos utilisateurs veulent le remplir dans votre application. Vous ne pouvez pas non plus vous en tirer par rastérisation, car le formulaire fait pousser des lignes au fil de la saisie, et la mise en page après la troisième ligne n’est pas celle qui a été livrée dans le fichier

Pourquoi le XFA dynamique reste-t-il un problème qui vaut d’être résolu ?

Le XFA dynamique persiste parce que les formulaires déployés survivent au format qui les portait. L’ISO 32000-1 §12.7.8 décrit le XFA comme une entrée /XFA sur le dictionnaire AcroForm portant un flux de paquet XDP, et l’ISO 32000-2 déprécie tout le mécanisme ; la dépréciation l’a retiré de la feuille de route, pas du terrain, et les formulaires rédigés contre la spécification XFA 3.3 sont toujours émis et toujours légalement contraignants. Le XFA statique peut être réduit à d’ordinaires annotations widget, et HotPDF le fait quand vous appelez ApplyXFAAsAcroForm, avec les compromis couverts dans l’aplatissement des formulaires XFA en champs AcroForm. Le XFA dynamique est une autre bête : ses plages occur, son texte extensible et ses scripts calculate font de l’ensemble de champs une fonction des données, si bien qu’il n’y a pas de liste d’annotations fixe vers laquelle aplatir tant que l’utilisateur n’a pas fini de taper. C’est la brèche que TXFAWidgetRuntime comble, en gardant le DOM XFA vivant, en recalculant la mise en page après chaque édition acceptée, et en remettant à votre hôte un tableau plat de widgets positionnés à dessiner et à tester au clic

Que remet le runtime à une application hôte ?

Il vous remet de la géométrie et de l’état, et rien qui suppose une boîte à outils UI. TXFAWidgetRuntime expose WidgetCount et Widgets[I] comme des enregistrements TXFAWidgetState portant ID, Name, Kind, PageIndex, Bounds en points PDF, Value, EditValue et les drapeaux Focused, Editing, ReadOnly, Valid, tandis que la peinture, le dessin du caret et le routage clavier restent dans votre code. L’identité du widget est stable et ordinale : chaque widget reçoit un ID de la forme name[n], où n compte les occurrences antérieures de ce nom de champ dans l’ordre de mise en page, si bien que la deuxième ligne d’un sous-formulaire répétitif est amount[1]. Cette identité survit à une reconstruction, et c’est elle que parlent FocusWidget, BeginEdit, DispatchEvent et HitTest. Pour un document déjà ouvert dans une instance THotPDF, CreateLoadedXFAWidgetRuntime extrait les paquets XDP, prend la première boîte de page comme taille de page de mise en page, et renvoie nil quand le fichier ne porte aucun XFA

var
  Pdf: THotPDF;
  Runtime: TXFAWidgetRuntime;
  WidgetID: AnsiString;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('claim-dynamic.pdf');
    Runtime := Pdf.CreateLoadedXFAWidgetRuntime;   // nil quand il n'y a pas de /XFA
    if Runtime = nil then
      Exit;
    try
      for I := 0 to Runtime.WidgetCount - 1 do
        Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
          [string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
           Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
           Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
           string(Runtime.Widgets[I].Value)]));
      // test de clic en espace page, le widget le plus haut gagne
      if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
        Runtime.BeginEdit(WidgetID);
    finally
      Runtime.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Que faut-il d’atomique quand un champ est validé ?

Tout ce que l’édition peut toucher, ce qui est considérablement plus que la valeur du champ. CommitEdit appelle CaptureSnapshot avant d’écrire quoi que ce soit, et ce snapshot couvre quatre choses : le DOM XFA sérialisé par TXFADocument.SaveToBytes, le tableau complet des enregistrements d’interaction TXFAWidgetState, les compteurs LastCalculationPasses et LastReflowPasses, et le Warnings.Count courant. Ne sauvegarder que les valeurs de nœuds est le raccourci tentant et il est faux, car un script calculate ou une liaison non résolue peut appeler EnsureValueNode et matérialiser des nœuds de données qui n’existaient pas au début de l’édition ; une restauration des seules valeurs n’a aucun moyen de les retirer, si bien qu’une édition rejetée laisserait un résidu structurel permanent dans le paquet datasets. La séquence de validation elle-même est stricte — écrire la valeur candidate, exécuter validate pour le champ édité, exécuter calculate jusqu’à un point fixe, puis reflow jusqu’à stabilité de la mise en page — et tout échec à toute étape passe par FailAndRestore, qui recharge les octets du snapshot dans un TXFADocument frais, reconstruit la liste des widgets, réapplique les états d’interaction enregistrés, remet les compteurs à zéro et ramène Warnings à sa longueur de snapshot. LastDiagnostic porte la raison en cas d’échec, et porte le littéral XFA transaction rollback failed dans le cas pathologique où la restauration elle-même lève

HotPDF traite une validation de champ XFA comme une transaction, capturant le DOM sérialisé, chaque état de widget, les compteurs de passes et le compte d’avertissements avant de valider, calculer et reflow, puis publiant ou restaurant les quatre ensemble
CommitEdit fait un snapshot de quatre sortes d’état avant d’écrire quoi que ce soit, si bien qu’un validate, calculate ou reflow échoué ne laisse aucun résidu structurel derrière
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // en lecture seule, ou widget inexistant
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // plage invalide, ou surrogate scindé
    Exit;
  end;
  Result := Runtime.CommitEdit;             // tout ou rien
  if not Result then
    // document, widgets, compteurs et avertissements sont déjà revenus à
    // l'état pré-édition ; le widget focalisé est simplement marqué invalide
    ShowMessage(Runtime.LastDiagnostic);
end;

ReplaceSelection mérite une note à part, car c’est là qu’une entrée malformée est la moins chère à rejeter. Elle refuse une sélection qui scinde une paire surrogate UTF-16, refuse un texte de remplacement contenant un surrogate haut ou bas non apparié, et refuse tout résultat plus long que MaxValueChars. Attraper cela à la couche des frappes signifie que la machinerie transactionnelle n’a jamais à dérouler un caractère hors plan astral à moitié écrit

Reconstruire dans une liste privée, publier en un seul échange

Une reconstruction de widgets ne doit jamais être observable à moitié finie, si bien que RebuildWidgets construit un TObjectList propriétaire complètement séparé et l’échange en place avec une seule affectation à la fin. La raison n’est pas esthétique : TXFALayoutEngine.ComputeLayout tourne pendant que la reconstruction est en vol et rappelle le code hôte à travers la fonction MeasureText que vous avez fournie, et elle peut lever EXFAWidgetRuntimeError quand la limite de widgets est atteinte. Si le runtime mutait sa liste vivante en place, l’un ou l’autre chemin laisserait l’hôte tenant une liste mi-ancienne mi-nouvelle, avec des pointeurs DataNode vers un document sur le point d’être ramené en arrière. La convergence du reflow est alors décidée par LayoutSignature, une chaîne construite à partir du compte de widgets plus chaque ID, index de page et boîte englobante arrondie à quatre décimales : CommitEdit reconstruit, compare les signatures, et répète jusqu’à ce que deux signatures consécutives correspondent ou que le budget de passes soit épuisé. Quand la signature n’a jamais changé du tout, LastReflowPasses reste à 0, ce qui permet de distinguer une édition de valeur seule d’une édition qui a réellement fait grandir le formulaire, et l’état d’interaction traverse chaque reconstruction par widget ID, si bien que le focus et l’édition en cours survivent à une insertion de ligne

Le runtime XFA de HotPDF reconstruit sa liste de widgets dans une liste propriétaire séparée pendant que la mise en page tourne et rappelle le code de mesure hôte, puis publie la liste finie avec une seule affectation que l’hôte ne peut observer à moitié faite
La reconstruction se fait dans une liste privée car ComputeLayout peut lever en vol, et LayoutSignature décide quand deux reflows consécutifs ont convergé

Pourquoi un champ lié lirait-il le mauvais enregistrement ?

Parce que le script a tourné sans contexte de données. Un champ portant un <bind match="dataRef" ref="$record.actual"/> explicite et un champ nommé d’après ce même nœud de données sont deux widgets différents pointés vers une valeur, et un sous-formulaire répétitif avec <occur max="2"/> produit plusieurs widgets qui partagent un nom et ne diffèrent que par la ligne de données à laquelle ils appartiennent ; évaluez validation et calcul contre la racine du document et chacun d’eux résout this vers le premier nœud correspondant de tout le paquet datasets, si bien que la ligne deux valide silencieusement la ligne une. HotPDF évite cela en stockant le DataNode résolu sur chaque entrée de widget quand la mise en page le produit, puis en filetant ce nœud à travers les deux appels HPDFXFAEvaluateFieldScript, pour xfskValidate comme pour xfskCalculate. Le même contexte décide contre quel nœud EnsureValueNode crée quand un calcul cible une liaison qui n’existe pas encore, et quand aucune liaison ne peut être résolue la validation échoue proprement avec XFA calculation target is not bound plutôt que d’écrire dans la mauvaise ligne. La sémantique FormCalc derrière ces scripts fait écho à ce que les documents AcroForm obtiennent des actions décrites dans les scripts format et calculate AcroForm, mais les règles de résolution ici sont scopées XFA plutôt que scopées par nom de champ

Les budgets sont vérifiés avant les effets de bord, pas après

Chaque limite du runtime est une précondition, car un budget appliqué après que l’allocation a déjà eu lieu n’est pas un budget. TXFAWidgetRuntimeOptions.Default livre MaxWidgets à 10000, MaxValueChars à 1048576, MaxCalculationPasses à 16 et MaxReflowPasses à 4, et les TXFAFormScriptOptions par défaut portent MaxOperations à 100000 avec MaxElapsedMilliseconds à 500. En dessous, le DOM XFA applique ses propres TXFADOMLimits : plafonds de 128 MB sur l’entrée et la sortie décompressées, au plus 1024 paquets cousus ensemble, 1000000 nœuds, et une profondeur d’imbrication de 256. Deux détails comptent plus que les nombres eux-mêmes. D’abord, les budgets de script sont à l’échelle de la transaction plutôt que par script : CommitEdit sème un unique compteur d’opérations restantes et une seule échéance monotone, et chaque invocation validate et calculate tire sur ce même compteur et ne reçoit que les millisecondes encore restantes, si bien qu’un formulaire avec deux cents champs calculants ne peut pas dépenser les 500 ms complets deux cents fois. Ensuite, l’échéance vient d’une fonction MonotonicMilliseconds injectable, ce qui rend le comportement temporel reproductible dans une suite de tests au lieu d’un pile ou face sur un agent de build chargé

Couches de budgets dans le runtime XFA de HotPDF, des limites de widgets et de valeurs aux limites d’opérations et de temps de script jusqu’aux plafonds du DOM XFA, avec un compteur d’opérations et une échéance partagés par chaque appel d’une transaction
Les budgets de script sont à l’échelle de la transaction plutôt que par script, si bien que deux cents champs calculants ne peuvent chacun réclamer 500 ms frais
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // défaut 10000
  Options.MaxCalculationPasses := 8;                       // défaut 16
  Options.MaxReflowPasses := 2;                            // défaut 4
  Options.ScriptOptions.Limits.MaxOperations := 20000;     // toute la transaction
  Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
  Options.MeasureText :=
    function(const AText: UnicodeString; const AFont: TXFAFontSpec;
      AMaxWidth: Double): TXFATextExtent
    begin
      Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
    end;
  Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
  try
    Runtime.OnLayoutChanged :=
      procedure
      begin
        RepaintAllPages;   // déclenché seulement quand le reflow a réellement déplacé des widgets
      end;
    // ... piloter le formulaire ...
  finally
    Runtime.Free;
  end;
end;

Où le runtime s’arrête, et pourquoi il le dit à voix haute

Le runtime n’est délibérément pas un moteur de script XFA généraliste. DispatchEvent gère nativement les activités enter et exit en déplaçant le focus, et pour toute autre activité portant un script il refuse avec un diagnostic spécifique et stable au lieu de faire semblant : les scripts mentionnant addInstance, removeInstance ou instanceManager renvoient XFA runtime does not support event-driven instance mutation, les scripts touchant .presence renvoient l’équivalent de présence, et tout le reste renvoie XFA runtime does not support this event script. Un refus prévisible sur lequel vous pouvez brancher vaut mieux qu’une émulation partielle qui marche sur votre fichier d’exemple et diverge sur celui du client

Le modèle de threading est tout aussi franc : une instance de runtime appartient à un thread, sans verrouillage interne, car le moteur de mise en page revient dans les rappels de mesure hôte et un verrou autour de cela est un interblocage attendant un repaint. Le contenu riche à l’intérieur des champs suit la même ligne conservatrice qu’ailleurs dans la bibliothèque, où les charges exData sont gérées comme décrit dans XFA exData texte riche et hyperliens, et les widgets signature et bouton reviennent en ReadOnly tandis que les sortes d’UI non prises en charge apparaissent comme xwkUnsupported plutôt que comme une boîte de texte éditable qui perd discrètement des données

Mis bout à bout, voilà une réponse praticable au XFA dynamique en Delphi : garder le DOM vivant, faire de chaque édition une transaction qui atterrit complètement ou ne laisse rien, borner chaque passe, et être explicite sur ce qui est hors périmètre. Si vous l’évaluez pour un flux de sinistres, de fiscalité ou de prestations, le runtime XFA arrive dans le composant PDF HotPDF pour Delphi, à côté des chemins AcroForm, d’aplatissement et de rendu que ces projets finissent généralement par besoin d’avoir ensemble