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
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
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é
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