Article technique

Import FDF en Delphi : réparer le zéro silencieux

Avant la v3.539.30, TPDFlib.ImportAnnotationsFromFDFString dans losLab PDF Library renvoyait le nombre d'entrées d'annotations FDF qu'il avait parsées sans en ajouter aucune au document : chaque entrée était comptée, chaque entrée était jetée. Depuis la v3.539.30, l'importeur FDF lit les clés dans n'importe quel ordre, parse /Rect correctement et indépendamment de la locale, et l'exporteur correspondant écrit le vrai /Rect de l'annotation, si bien qu'un export, un import et un second export produisent un FDF identique à l'octet près. Le reste de cette note explique comment un décalage de départ erroné a produit une défaillance silencieuse parfaite, quels trois autres défauts se cachaient derrière, et comment vérifier un import vous-même au lieu de faire confiance à la valeur de retour

Le scénario est banal. Un relecteur annote un contrat, les commentaires voyagent dans un fichier FDF (Acrobat appelle ça Export Comments), et votre service Delphi les fusionne dans une copie propre avec ImportAnnotationsFromFDF. L'appel renvoie 7, le journal dit « 7 comments imported », le job passe au vert, et le PDF de sortie ne contient aucun commentaire. Rien ne s'est levé, rien n'a prévenu, et le nombre avait l'air plausible parce que c'était le vrai compte des entrées du fichier. C'est la pire forme qu'un bug puisse prendre : une fonction dont le seul signal de succès est un compteur calculé indépendamment du travail qu'il prétend rapporter

Pourquoi ImportAnnotationsFromFDFString annonçait un succès sans rien ajouter ?

L'importeur lisait chaque /Subtype comme une chaîne vide, et le helper qui crée l'annotation sortait aussitôt sur un sous-type vide tandis que l'appelant incrémentait le résultat quand même. Le chercheur de clés renvoyait la position juste après /Subtype, c'est-à-dire l'espace blanc avant la valeur. ReadName démarrait sur cet espace et s'arrêtait au premier caractère blanc, donc il s'arrêtait avant d'avoir rien lu. AddAnnotationToPage refuse de construire une annotation sans sous-type, choix défensif parfaitement juste pris isolément, mais c'était une procedure sans valeur de retour, et Inc(Result) se trouvait dehors. Chaque garde était raisonnable seule ; ensemble, elles convertissaient « rien n'a marché » en « tout a marché ». La correction fait que ReadName saute les espaces blancs, exige le / initial d'un objet name PDF, et s'arrête à n'importe quel délimiteur, y compris [, ( et ), si bien que /Subtype/Text et /Subtype /Text donnent tous deux Text

PDFlibPas ImportAnnotationsFromFDFString trouvait /Subtype, démarrait ReadName sur l'espace blanc après la clé si bien qu'il renvoyait un nom vide, AddAnnotationToPage sortait sur le sous-type manquant, et l'appelant incrémentait le résultat quand même, en annonçant sept commentaires importés sans en ajouter aucun au document
Chaque garde était raisonnable isolément ; ensemble elles convertissaient rien n'a marché en tout a marché, voilà pourquoi la valeur de retour ne doit jamais être la seule chose qu'un test d'import vérifie

La valeur de retour méritait qu'on s'en occupe même après cette correction. Jusqu'à la v3.539.39, ImportAnnotationsFromFDFString incrémentait encore son résultat pour chaque dictionnaire bien formé du tableau /Annots, y compris les entrées dont le /Page base 0 était hors bornes ou dont le /Subtype manquait, deux cas qui sont sautés. Depuis PDFlibPas v3.539.40, ImportAnnotationsFromFDFString et ImportAnnotationsFromFDF renvoient le nombre d'annotations réellement ajoutées, comme l'import XFDF : le helper FDF AddAnnotationToPage renvoie maintenant un Boolean et le compteur ne bouge qu'en cas de succès. Mesurer le document reste le contrôle le plus fort, parce que ce contrôle tient aussi sur les anciennes versions, donc le croquis ci-dessous compare AnnotationCount sur chaque page avant et après l'import

function TotalAnnotations(Lib: TPDFlib): Integer;
var
  Page, Saved: Integer;
begin
  Result := 0;
  Saved := Lib.SelectedPage;
  for Page := 1 to Lib.PageCount do
    if Lib.SelectPage(Page) = 1 then
      Inc(Result, Lib.AnnotationCount);   // par page sélectionnée, widgets compris
  Lib.SelectPage(Saved);
end;

var
  Lib: TPDFlib;
  Before, Reported, Added: Integer;
begin
  Lib := TPDFlib.Create;
  try
    Lib.LoadFromFile('contract.pdf', '');
    Before := TotalAnnotations(Lib);
    Reported := Lib.ImportAnnotationsFromFDF('review-comments.fdf');
    Added := TotalAnnotations(Lib) - Before;
    if Added <> Reported then   // égal depuis la v3.539.40
      Writeln(Format('Importer reported %d, %d landed on a page', [Reported, Added]));
    Lib.SaveToFile('contract-reviewed.pdf');
  finally
    Lib.Free;
  end;
end;

Trois autres défauts derrière le premier

Corriger le sous-type seul aurait exposé trois bugs de plus dans la même fonction, chacun resté invisible uniquement parce qu'aucune annotation n'atteignait jamais une page. D'abord, ReadNumber prenait sa position en paramètre par valeur, donc lire les quatre nombres /Rect à la suite relisait quatre fois le même endroit, et il ne sautait pas le [ ouvrant, si bien qu'en pratique il ne lisait rien du tout. Ensuite, FindKey partageait un seul curseur avançant entre toutes les recherches. L'exporteur écrit /Subtype, /Rect, /Page, /Contents, /T, /Subj, mais l'importeur cherchait dans l'ordre /Subtype, /Contents, /T, /Subj, /Page, /Rect ; une fois le curseur passé /Contents, la recherche de /Page et /Rect filait au-delà de l'entrée courante et ne trouvait rien ou appariait les clés de l'annotation suivante. La bibliothèque ne savait pas lire sa propre sortie. Enfin, les nombres passaient par PLStrToFloat, qui suit le séparateur décimal du système. ISO 32000-1 §12.7.7 définit le FDF comme syntaxe d'objets PDF, et les clés de dictionnaire en PDF sont sans ordre (§7.3.7), donc tout parseur FDF qui suppose un ordre de clés est faux par construction, quel que soit l'outil qui a produit le fichier

L'importeur réparé borne d'abord chaque entrée. FindDictEnd marche depuis le << ouvrant jusqu'à son >> apparié, en suivant les dictionnaires imbriqués et en sautant les corps de chaînes littérales avec leurs échappements antislash, donc un >> dans un commentaire tel que (see section >> 4) ne peut plus terminer l'entrée en avance. Chaque recherche de clé démarre alors au début de l'entrée elle-même et se limite à sa fin, ce qui rend l'ordre des clés sans importance et empêche une annotation d'emprunter le /Page d'une autre. L'appariement des clés accepte aussi un délimiteur collé au nom, parce que /Contents(Hi) est aussi valide que /Contents (Hi), tandis que la règle du mot entier empêche /Subj d'apparier le début de /Subtype et /T d'apparier /Type. ReadNumber prend maintenant sa position en paramètre var, saute les espaces blancs et le [, et parse avec PLTryStrToFloatInvariant, qui échoue en douceur sur un jeton mal formé au lieu de lever. Si l'un des quatre nombres du rectangle échoue, les quatre retombent à zéro plutôt que de produire un rectangle à moitié lu

PDFlibPas FindDictEnd borne désormais chaque annotation FDF de son << ouvrant au >> apparié, si bien que chaque recherche de clé repart du début de l'entrée et s'arrête à sa fin, et ReadNumber prend une position var, saute le crochet et parse avec PLTryStrToFloatInvariant
Le curseur partagé ne savait pas lire l'export de la bibliothèque elle-même : une fois passé /Contents, les recherches de /Page et /Rect filaient dans les clés de l'annotation suivante, donc l'ordre des clés n'a plus le droit de compter

Pourquoi les allers-retours FDF déplaçaient-ils chaque annotation de sa propre hauteur ?

L'ancien exporteur écrivait un rectangle dans le mauvais modèle de coordonnées. Le /Rect d'une annotation est [llx lly urx ury] en espace utilisateur par défaut (ISO 32000-1 §12.5.2, rectangles définis en §7.9.5), et le FDF porte le même tableau. ExportAnnotationsToFDFString, lui, appelait GetAnnotRectEx, qui rapporte Left, Top, Width et Height dans les coordonnées de dessin de la bibliothèque, l'espace que SetOrigin contrôle, et les sérialisait en [L T L+W T+H]. L'importeur, une fois fonctionnel, réécrivait ces quatre valeurs telles quelles comme rectangle PDF, si bien que le bord haut se posait là où appartenait le coin bas-gauche et que chaque aller-retour montait l'annotation de sa propre hauteur. L'exporteur copie maintenant les nombres /Rect propres à l'annotation, trois décimales, séparateur point, pas d'exposant, et ne retombe sur le rectangle calculé que si le tableau stocké manque ou ne compte pas quatre nombres

PDFlibPas sérialisait le /Rect FDF en left, top, width, height dans les coordonnées de dessin, si bien que réimporter ces quatre nombres comme llx lly urx ury posait le bord haut là où appartenait le coin bas-gauche et montait chaque annotation de sa propre hauteur à chaque aller-retour
L'exporteur copie désormais les nombres /Rect de l'annotation elle-même — trois décimales, séparateur point, pas d'exposant — et le test de régression compare un second export à l'octet près avec le premier

Le test de régression qui verrouille ça vaut la peine d'être copié, parce qu'il affirme sur le document et sur un second export, pas sur la valeur de retour de l'importeur. Notez le compte attendu de 2 : AddNoteAnnotation crée une annotation Text plus sa Popup, et les deux voyagent. Le test fait aussi passer export et import sous un séparateur décimal à virgule, et c'est là que vit l'autre moitié de cette histoire

var
  Source, Target: TPDFlib;
  FDF: AnsiString;
  OldSep: Char;
begin
  Source := TPDFlib.Create;
  Target := TPDFlib.Create;
  try
    Source.NewPages(1);                     // deux pages désormais
    Source.SelectPage(2);
    Source.AddNoteAnnotation(50.5, 60.25, 0, 80, 80, 120, 60,
      'Reviewer', 'Check this', 0.25, 0.5, 0.75, 0);
    Target.NewPages(1);

    OldSep := FormatSettings.DecimalSeparator;
    FormatSettings.DecimalSeparator := ',';   // simule un bureau allemand ou français
    try
      FDF := Source.ExportAnnotationsToFDFString;   // écrit toujours /Rect [50.5 ...
      Target.ImportAnnotationsFromFDFString(FDF);
    finally
      FormatSettings.DecimalSeparator := OldSep;
    end;

    Target.SelectPage(2);
    Assert(Target.AnnotationCount = 2);           // la note et sa popup
    Assert(Target.GetAnnotType(1) = 'Text');
    Assert(Target.ExportAnnotationsToFDFString = Source.ExportAnnotationsToFDFString);
  finally
    Target.Free;
    Source.Free;
  end;
end;

Soyez clair sur ce que la voie FDF transporte. L'importeur reconstruit chaque entrée en dictionnaire avec /Type, /Subtype, /Rect, /Contents, /T et /Subj ; couleur, drapeaux, style de bordure, liens popup et flux d'apparence ne font pas partie de cette route, et l'exporteur saute les annotations Widget parce que les champs de formulaire relèvent des méthodes de données de formulaire. La carte d'ensemble de quelles données passent par quelle méthode se trouve dans la présentation de l'échange de données de formulaire FDF, XFDF et XFA, et si vous devez inspecter ce qui est réellement arrivé, les lecteurs par index tels que GetAnnotType, GetAnnotTitle et GetAnnotContentsEx sont couverts dans l'introspection des outlines, annotations et actions

Comment lire des fichiers FDF et XFDF à décimales virgule venus d'exports plus anciens ?

Pour le FDF la réponse est sans ambiguïté : une virgule n'est pas un délimiteur dans la syntaxe PDF, donc un jeton de nombre qui contient exactement une virgule et aucun point ne peut être qu'une décimale écrite sur une machine à locale virgule. Les versions antérieures écrivaient bien de tels fichiers, par exemple /Rect [10,500 20,250 40,750 60,125], et le nouveau ReadNumber transforme cette virgule unique en point avant de parser. Un jeton à deux virgules, ou avec une virgule et un point, est rejeté plutôt que deviné. Le lecteur ne consomme pas non plus la notation exposant, ce qui colle à ISO 32000-1 §7.3.3 : les nombres PDF ne l'utilisent jamais

Le XFDF est plus coriace, parce que dans les attributs XML la virgule est le séparateur. Le XFDF standard (ISO 19444-1) écrit rect="50.5,80.25,70.75,100.125" et dashes="4,2", tandis que la v3.539.28 et antérieures, sur un système à locale virgule, écrivaient rect="50,500 80,250 70,750 100,125" et opacity="0,600", et échouaient aussi avec EConvertError en lisant un opacity="0.6" standard. Depuis la v3.539.29, les deux sens sont invariants, et la forme ancienne est reconnue par XFDFNormalizeLegacyDecimals seulement quand l'attribut se découpe sur les espaces blancs en exactement le nombre attendu de jetons (quatre pour rect, un pour opacity et width) et que chaque jeton a la forme chiffres-virgule-chiffres. Un rect standard n'apparie jamais : c'est soit un jeton à trois virgules, soit des jetons qui finissent par une virgule. dashes est délibérément laissé de côté, parce que 4,2 peut être deux longueurs de tiret ou un 4.2 ancien, et aucune règle ne peut les départager

const
  // Clés dans un autre ordre que l'exporteur, plus décimales à virgule d'un vieil export
  LegacyFDF: AnsiString = '%FDF-1.2'#10'1 0 obj'#10'<< /FDF << /Annots ['#10 +
    '<< /Rect [10,500 20,250 40,750 60,125] /Page 0 /Contents (First) ' +
    '/Subtype /Text /T (Alpha) /Type /Annot >>'#10 +
    '] >> >>'#10'endobj'#10'trailer'#10'<< /Root 1 0 R >>'#10'%%EOF'#10;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;               // un document neuf a une page
  try
    Lib.ImportAnnotationsFromFDFString(LegacyFDF);
    Assert(Lib.AnnotationCount = 1);
    Assert(Lib.GetAnnotTitle(1) = 'Alpha');
    // Réexporté en XFDF à décimales point : rect="10.500 20.250 40.750 60.125"
    Writeln(Lib.ExportAnnotationsToXFDFString);
  finally
    Lib.Free;
  end;
end;

Que doit réellement affirmer un test d'import d'annotations ?

Un test d'import utile affirme sur l'état du document cible, jamais seulement sur ce que l'importeur dit de lui-même. Rien dans la suite de tests ne vérifiait AnnotationCount après un import FDF, et la valeur de retour, le seul nombre que quiconque regardait, était justement le seul nombre que le bug laissait intact. Trois assertions auraient attrapé chaque défaut décrit ici : le compte d'annotations sur la page attendue, un champ relu via GetAnnotType ou GetAnnotContentsEx, et un second export comparé à l'octet près avec le premier. La même discipline vaut pour toute API qui réécrit la structure du document en masse, y compris la consolidation de champs décrite dans la fusion de champs de formulaire dupliqués : contrôlez l'arbre résultant, pas un total renvoyé. Les méthodes d'annotations FDF et XFDF, avec leurs variantes fichier et chaîne, sont livrées dans losLab PDF Library for Delphi and C++Builder, et la v3.539.30 ou plus est la version à faire tourner si les commentaires doivent survivre au voyage, la v3.539.40 ou plus si le compte renvoyé doit correspondre à ce qui a été ajouté