Article technique

Sauvegarde XFA : sauts de ligne, emoji et restoreState

PDFium Component sauvegarde exactement les valeurs de formulaires XFA éditées, à travers sauvegarde et réouverture, quand il tourne avec le runtime V8 Windows pdfium.v8.dll livré en v3.125.2 ou ultérieur. Les runtimes plus anciens ajoutaient des sauts de ligne aux valeurs de champs, réduisaient les emoji à un caractère BMP sans rapport, sautaient silencieusement les sauvegardes XFA à flux unique et pouvaient avaler une écriture finale échouée. Un symptôme à la réouverture n’est pas du tout un défaut de bibliothèque : un formulaire dynamique dont le subform racine n’a pas restoreState="auto" reconstruit son layout depuis le modèle

Les rapports de bug là-dessus se ressemblaient tous. Un client remplit un formulaire de réclamation XFA dans un visualiseur Delphi, sauvegarde, rouvre, et quelque chose est légèrement de travers. Une zone commentaires vide tient désormais une ligne blanche, et après une seconde sauvegarde elle en tient deux. Un nom tapé avec un emoji revient avec un glyphe d’usage privé. Personne ne reçoit d’erreur, et c’est ce qui rend ces bugs coûteux : la dérivation fait surface des semaines plus tard dans l’export de quelqu’un d’autre

Qu’est-ce qui tourne mal quand un formulaire XFA est sauvegardé puis rouvert ?

Quatre défauts distincts du chemin de sauvegarde XFA natif causaient la dérivation de valeurs, et chacun se cachait derrière une sauvegarde d’apparence réussie. Deux venaient de la sérialisation, un de la disposition de stockage à flux unique, et un de l’écrivain PDF lui-même. Le tableau met chaque symptôme en regard de sa cause et de la version où PDFium Component l’a corrigé

Symptôme à la réouvertureCauseCorrigé en
Un champ vide tient un saut de ligne ; les valeurs gagnent une nouvelle ligne par sauvegardeLes deux écrivains XFA inséraient des sauts de ligne de layout après les balises ouvrantesv3.125.2, pdfium.v8.dll
U+1F642 revient en U+F642, ou l’emoji disparaît du paquet formTroncature wchar_t 16 bits au décodage ; filtrage des surrogates dans le sérialiseur de formulairev3.125.2, pdfium.v8.dll
Les éditions d’un document XFA à flux unique ont simplement disparuLa sauvegarde native rejetait la disposition de flux, mais la valeur de retour était ignoréev3.125.2 ; commentaires et instructions de traitement conservés depuis v3.126.0
Fichier tronqué alors que la sauvegarde annonçait le succèsL’écriture finale en tampon échouait après que l’écrivain avait déjà renvoyé le succèsruntime V8 v3.125.2 ; pdfium.dll ordinaire v3.125.3
Un formulaire dynamique de trois pages se rouvre en deux pagesLe subform racine ne demande pas restoreState="auto"Rédaction du formulaire, pas un défaut de bibliothèque

Des écrits antérieurs concluaient que les éditions de champs XFA ne pouvaient pas du tout être persistées avec PDFium, ce qui était exact pour les runtimes de l’époque. Le runtime V8 plus récent sauvegarde nativement les valeurs XFA, donc une édition faite dans le formulaire vivant atteint le paquet datasets sauvegardé sans chirurgie de paquets de votre côté

Quel runtime PDFium sauvegarde les valeurs XFA ?

La fidélité de sauvegarde XFA dépend de la DLL native, pas du wrapper Delphi, donc la première vérification est de savoir quel runtime votre processus a réellement chargé. PDFium Component livre deux builds Windows par architecture : le pdfium.dll ordinaire, compilé sans V8 ni XFA, et le pdfium.v8.dll, qui embarque le moteur JavaScript et le runtime de formulaires XFA. Seul pdfium.v8.dll peut exécuter un formulaire XFA, donc chaque correctif XFA décrit ici vit là, à commencer par les bibliothèques V8 Win32 et Win64 recompilées en v3.125.2

Le correctif d’écriture finale est du code d’écrivain PDF générique, donc il compte aussi pour les documents ordinaires. v3.125.3 a recompilé les bibliothèques pdfium.dll ordinaires pour porter cette même réparation. Source partagée ne prouve pas comportement partagé : tant que le binaire n’est pas recompilé, la vieille DLL garde le vieux bug

Un second piège siégeait dans le chargeur. Avant v3.125.2, régler EnableV8Engine à True faisait que le binding choisissait le nom pdfium.v8.dll par défaut et ignorait un chemin complet dans LibraryName. Une application qui pointait vers un runtime fraîchement déployé pouvait continuer de charger une copie plus ancienne depuis un autre dossier. Depuis v3.125.2, un LibraryName qui contient un répertoire sélectionne exactement ce fichier dans les deux modes de moteur, et un chemin manquant échoue au lieu de retomber sur une autre bibliothèque embarquée

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // Un répertoire dans LibraryName épingle ce fichier exact (v3.125.2 et ultérieur) ;
  // si le fichier manque, le chargement lève au lieu de retomber en arrière
{$IFDEF WIN64}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
  PDFium.EnableV8Engine := True;
  PDFium.LoadLibrary;  // échouer au démarrage, pas à la première sauvegarde
end;

Après l’ouverture d’un document, TPdf.XFA vous dit que le fichier contient du XFA et TPdf.XfaRuntimeAvailable vous dit que la DLL chargée peut réellement l’exécuter. Si vous devez aussi distinguer les formulaires statiques et dynamiques, TPdf.FormType renvoie ftXfaFull ou ftXfaForeground ; l’article détecter les formulaires XFA et extraire les paquets XFA en Delphi couvre ce sondage en détail

Pourquoi les champs XFA sauvegardés gagnent-ils des sauts de ligne en plus ?

Les champs XFA sauvegardés gagnaient des sauts de ligne parce que les deux écrivains XFA natifs, l’écrivain d’éléments XML générique et le sérialiseur du paquet form, faisaient de la jolie impression avec un saut de ligne après les balises ouvrantes. Dans la plupart des XML, cette espace est cosmétique. Dans les données XFA non : quand le paquet datasets est analysé à nouveau, le texte entre <Comments> et </Comments> est la valeur du champ, saut de ligne compris. Un champ vide se rouvrait donc tenant un seul LF, et chaque cycle supplémentaire de sauvegarde et de réouverture pouvait en ajouter un autre

Schéma du cycle de sauvegarde XFA de PDFium Component où l’écrivain ajoute un saut de ligne après les balises ouvrantes, l’analyseur de la réouverture lit le LF entre les balises Comments comme valeur du champ, et chaque sauvegarde supplémentaire ajoute un autre saut de ligne jusqu’à ce que v3.125.2 ne retire que l’espace synthétisé par le sérialiseur
Un cycle sauvegarde-réouverture plante le premier saut de ligne et chaque tour supplémentaire en ajoute un autre, voilà pourquoi la dérivation ne montrait sa pleine forme qu’à la seconde génération

La réparation évidente, rogner les valeurs au chargement, serait fausse. Les utilisateurs tapent des espaces de tête, des espaces de queue et du texte multi-lignes délibéré dans les champs XFA, et un bloc d’adresse ou un code à largeur fixe doit survivre octet pour octet. Le correctif v3.125.2 ne retire donc que l’espace que le sérialiseur lui-même a synthétisé autour des balises. Les valeurs utilisateur, les nœuds texte existants et les sections CDATA passent intacts, donc " indented" reste indenté et un champ volontairement vide reste vide

Pourquoi un emoji revient-il en un autre caractère ?

Un emoji revenait faux parce que le wchar_t Windows fait 16 bits de large, et deux chemins de décodage stockaient une valeur scalaire Unicode complète dans un seul wchar_t. Le décodeur de flux UTF-8 et l’analyseur des références de caractères numériques telles que &#x1F642; faisaient tous deux cela. U+1F642, le visage légèrement souriant, ne tient pas dans 16 bits, donc les bits hauts tombaient et U+F642 apparaissait à la place : un point de code dans la zone à usage privé que la plupart des polices rendent comme une boîte ou rien

Le sérialiseur de formulaire avait le problème opposé. Il filtrait les caractères un wchar_t à la fois, voyait deux unités de code surrogate invalides isolément, et les jetait toutes les deux, si bien que l’emoji disparaissait complètement du paquet form. En v3.125.2, le décodeur consomme chaque valeur scalaire complètement et émet une paire de surrogates correcte. Quand il ne reste qu’un emplacement de sortie, il garde la surrogate basse en attente et ne signale pas de fin de flux tant que cette unité est encore en tampon. Une séquence UTF-8 coupée entre deux blocs de lecture est reportée à la lecture suivante au lieu d’être jetée. L’exportateur de formulaire garde désormais les paires de surrogates valides ensemble, et les références de caractères numériques produisent des paires correctes elles aussi

Schéma de gestion des surrogates de PDFium Component où U+1F642 arrive comme la paire UTF-16 D83D DE42 et deux chemins défectueux le corrompent : les décodeurs wchar_t 16 bits tronquent le scalaire en U+F642 dans la zone à usage privé, tandis que le sérialiseur de formulaire filtre les surrogates solitaires et jette l’emoji complètement
Le wchar_t Windows fait 16 bits de large, donc un scalaire qui exige une paire de surrogates perdait sa moitié haute ou disparaissait du paquet, jusqu’à ce que les deux chemins apprennent à garder les paires ensemble

Des données de test Latin-1 ne montrent jamais rien de tout cela, donc chaque test d’aller-retour XFA a besoin d’au moins un caractère du plan supplémentaire

XFA à flux unique et échecs de sauvegarde que personne ne voyait

Un document XFA à flux unique perdait ses éditions parce que l’aide de sauvegarde native rejetait cette disposition de stockage et que son appelant ignorait l’échec. ISO 32000-1 §12.7.8 permet à l’entrée /XFA du dictionnaire de formulaire interactif d’être soit un tableau de noms de paquets et de flux, soit un flux unique tenant tout le document XDP. Les tableaux de paquets sont le cas courant, mais les flux uniques sont parfaitement légaux, et la sauvegarde PDF s’achevait comme si de rien n’était pendant que les données du formulaire restaient à leurs vieilles valeurs

Depuis v3.125.2, le runtime V8 gère le sous-ensemble à flux unique supporté. Il exporte d’abord les deux paquets vivants, datasets et form, vers une zone de montage et les valide, et ce n’est qu’ensuite qu’il remplace les paquets correspondants dans l’XDP d’origine. Les autres paquets et les déclarations d’espaces de noms racines sont conservés. Si le montage échoue, le flux XFA persistant n’est jamais touché et le document garde sa marque de modification

Les commentaires XML et les instructions de traitement ont demandé un soin supplémentaire parce que le DOM XML interne les jette. En v3.125.2, leur présence faisait échouer la sauvegarde carrément plutôt que de perdre du contenu en silence. v3.126.0 les préserve : avant l’analyse, chaque commentaire ou instruction de traitement est échangé contre un marqueur construit depuis un préfixe qui n’apparaît nulle part dans le texte d’origine. Après le remplacement des paquets vivants, chaque marqueur doit apparaître exactement une fois avant que le token d’origine soit restauré et que le flux soit écrit. Les tokens hors des paquets remplacés gardent donc leur texte et leur ordre, y compris les tokens dans le prologue, le template et les autres paquets

Certaines entrées sont encore refusées à dessein, et chaque refus est un échec de sauvegarde explicite :

  • Commentaires ou instructions de traitement à l’intérieur des paquets vivants datasets ou form, puisque leurs positions d’origine ne peuvent pas être projetées dans du contenu fraîchement exporté
  • Déclarations DTD et signatures XMLDSig, puisque réécrire l’XDP ne peut pas garder une signature XML valide
  • Encodage UTF-8 ou UTF-16 invalide, balises incomplètes, références de caractères invalides, entités inconnues et instructions de traitement malformées, qui sont rejetées au lieu d’être réparées en silence
Pipeline de sauvegarde XFA à flux unique de PDFium Component où les paquets vivants datasets et form sont exportés vers le montage, validés, puis remplacés dans l’XDP d’origine avec les commentaires préservés via des marqueurs, tandis que les échecs de montage et des entrées comme les DTD ou XMLDSig refusent la sauvegarde explicitement
L’export monté est validé avant que quoi que ce soit ne soit remplacé, donc une sauvegarde échouée laisse le flux XFA persistant intact et le document garde sa marque de modification

La sortie à flux unique est en UTF-8 et préserve le modèle de contenu XML, pas la disposition d’octets d’origine ni la déclaration d’encodage

Le dernier défaut siégeait sous le XFA. L’écrivain de fichiers natif met la sortie en tampon par blocs de 32 Kio et ne vidait le dernier bloc partiel que dans son destructeur, après que l’écrivain du document avait déjà annoncé le succès. Un disque plein ou une erreur d’E/S sur ce dernier bloc était invisible pour l’appelant. Depuis v3.125.2 dans le runtime V8 et v3.125.3 dans le runtime ordinaire, ce vidage final fait partie du résultat de sauvegarde, et la marque de modification XFA n’est effacée qu’après un vrai succès. Côté Delphi, TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean écrit dans un fichier temporaire à côté de la cible et ne le met en place que quand la sauvegarde renvoie True, si bien qu’une sauvegarde échouée laisse le fichier précédent intact

Pourquoi un formulaire XFA dynamique se rouvre-t-il avec moins de pages ?

Un formulaire XFA dynamique se rouvre avec moins de pages quand son subform racine ne déclare pas restoreState="auto", et c’est une décision de rédaction du formulaire plutôt qu’un défaut de PDFium Component. Dans XFA 3.3, restoreState sur le subform racine vaut manual par défaut. Sous manual, le processeur XFA ne restaure qu’un état limité depuis le paquet form sauvegardé et laisse le reste aux scripts de l’auteur. Les valeurs de champs sauvegardées et les comptes d’instances de subforms répétés reviennent quand même, mais les propriétés géométriques réglées à l’exécution non

Le cas qui a exposé cela était un formulaire de trois pages dont le script faisait grandir un subform à h="450pt". Le paquet form sauvegardé tenait la nouvelle hauteur, les valeurs et les comptes d’instances. À la réouverture, pourtant, le layout était reconstruit depuis les hauteurs du modèle et le formulaire se refoulait sur deux pages. Le runtime avait raison : le modèle n’avait jamais demandé la restauration automatique. Le déclarer sur le subform racine répare la réouverture :

<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
  <subform name="form1" layout="tb" restoreState="auto">
    <pageSet>
      <pageArea name="Page1">
        <contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
        <medium stock="letter"/>
      </pageArea>
    </pageSet>
    <subform name="Details" layout="tb" w="7.5in">
      <!-- champs ; les scripts peuvent changer h ou ajouter des instances à l’exécution -->
    </subform>
  </subform>
</template>

Si le modèle ne vous appartient pas, ne le rafistolez pas dans le visualiseur : un formulaire qui compte sur le mode manual attend de ses propres scripts qu’ils reconstruisent l’état. La repagination en direct pendant que l’utilisateur tape est un sujet séparé, traité dans comment PDFium Component suit les nombres de pages XFA dynamiques et les champs déplacés

Comment vérifier une sauvegarde XFA en Delphi ?

La seule vérification fiable d’une sauvegarde XFA, c’est de rouvrir le fichier sauvegardé dans une instance TPdf neuve et de relire les données stockées. TPdf.GetXfaDatasets renvoie le paquet datasets tel qu’il est stocké dans le document, pas le modèle de données XFA vivant, donc l’appeler avant la sauvegarde montre les vieilles valeurs. Après réouverture, il montre exactement ce qui a été écrit. Un document à flux unique n’a pas de paquets nommés séparément : PDFium rapporte tout l’XDP comme un paquet au nom vide, donc GetXfaPacketByName('datasets') et GetXfaDatasets ne renvoient rien, et le repli lit le flux complet via GetXfaFormPackets

uses
  System.SysUtils, PDFium, FPdfXfa;

function ReadSavedXfaData(const FileName: string): string;
var
  Pdf: TPdf;
  Packets: TXfaPacketList;
  Bytes: TBytes;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Bytes := Pdf.GetXfaDatasets;          // disposition en tableau de paquets
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // flux unique : un paquet sans nom
      if Length(Packets) = 1 then
      begin
        SetLength(Bytes, Length(Packets[0].Content));
        if Length(Bytes) > 0 then
          Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
      end;
    end;
    Result := TEncoding.UTF8.GetString(Bytes);  // la sortie XDP sauvegardée est en UTF-8
  finally
    Pdf.Free;
  end;
end;

La routine de sauvegarde commite ensuite l’édition en attente, vérifie le résultat de SaveAs et compare la valeur réouverte. TPdf.ClearFormFieldFocus tue le focus du formulaire, ce qui est le moment où PDFium commite le tampon d’édition du champ focalisé. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean remplit le champ focalisé par programme, mais il repose sur un focus que le wrapper suit via FocusFormField, qui parcourt les annotations widget. Une page XFA dynamique n’en a normalement aucune, donc là le texte arrive habituellement par saisie clavier dans TPdfView, et la fonction renvoie False quand aucun champ suivi n’a le focus

function XmlText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
end;

procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
  Expected: string);
var
  Saved: string;
begin
  // Remplissage scripté optionnel ; False signifie qu’aucun champ suivi n’a le focus
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // commite le tampon d’édition
  if not Pdf.SaveAs(FileName) then      // inclut le vidage final (v3.125.2+)
    raise EPdfError.CreateFmt('Saving %s failed', [FileName]);

  Saved := ReadSavedXfaData(FileName);
  if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
    Saved) = 0 then
    raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;

Traitez le test de sous-chaîne comme un test de fumée. Un élément vide peut être sérialisé en <Tag/>, des attributs peuvent apparaître sur les éléments de données, et l’échappement au-delà de & et < est un choix du sérialiseur. Pour des vérifications en production, chargez le XML rouvert avec un vrai analyseur XML et comparez le nœud texte de l’élément de données lié. Lancez aussi la vérification deux fois de suite, parce que le défaut de saut de ligne ne montrait sa pleine forme qu’à la seconde génération

Aide-mémoire : checklist de fidélité de sauvegarde XFA

  • Déployez pdfium.v8.dll en v3.125.2 ou ultérieur pour les formulaires XFA, et v3.125.3 ou ultérieur pour le pdfium.dll ordinaire, afin que le correctif d’écriture finale soit dans les deux
  • Pointez LibraryName vers un chemin complet et réglez EnableV8Engine à True ; un chemin manquant échoue au lieu de charger une autre copie
  • Confirmez TPdf.XFA et TPdf.XfaRuntimeAvailable après l’ouverture du document
  • Appelez ClearFormFieldFocus avant SaveAs pour que le champ focalisé soit commis
  • N’ignorez jamais le résultat booléen de SaveAs ; un résultat False laisse le fichier précédent en place
  • Vérifiez en rouvrant dans un TPdf neuf et en lisant GetXfaDatasets, avec repli sur GetXfaFormPackets pour le XFA à flux unique
  • Testez avec des valeurs vides, des espaces de tête, du texte multi-lignes, & et un caractère du plan supplémentaire, sur deux générations de sauvegarde
  • Attendez-vous à des échecs de sauvegarde explicites pour les DTD, XMLDSig et les commentaires dans les paquets vivants du XFA à flux unique
  • Si un formulaire dynamique perd sa géométrie d’exécution à la réouverture, vérifiez le subform racine pour restoreState="auto" avant de soupçonner la bibliothèque

Pour la structure de callbacks que le runtime XFA attend d’une application hôte, voir FPDF_FORMFILLINFO version 2 et l’ABI XFA en Delphi. Le runtime V8, le wrapper Delphi et C++Builder et le contrôle visualiseur font tous partie de PDFium Component pour Delphi et C++Builder, qui inclut les deux runtimes Windows pour Win32 et Win64