PDFlibPas, la PDF Developer Library losLab pour Delphi, écrit chaque nombre qu'il place dans un flux de contenu avec un séparateur décimal point et sans exposant, quoi qu'en disent les paramètres régionaux Windows. Depuis la v3.539.26, AddPageMatrix, ScalePage, DeskewPage, RedactRegion, la sortie texte-vers-chemin et la recoloration formatent leurs opérandes via PLDoubleToStrConst, et depuis la v3.539.33 les parseurs qui relisent ces nombres passent par PLTryStrToFloatInvariant au lieu de la locale système. Sur une machine allemande, française ou brésilienne, le même code produit maintenant les mêmes octets que sur une machine US, et c'est le seul comportement qu'un format de fichier puisse tolérer
Pourquoi une locale à virgule corrompt-elle un PDF sans erreur ?
Une locale à virgule corrompt un PDF en silence parce que la virgule n'est pas un caractère de nombre dans la syntaxe PDF, si bien que le dégât se lit comme des jetons valides au sens faux. Avant la correction, PLFloatToStr n'était rien de plus qu'un appel nu à FloatToStr, et FloatToStr suit FormatSettings.DecimalSeparator. Avec un séparateur virgule, AddPageMatrix(0.5, 0.5, 0, 0) écrivait 0,5 0 0 0,5 0 0 cm. ISO 32000-1 §7.3.3 admet des chiffres, une période et un signe initial dans un nombre, et rien d'autre, donc un parseur de contenu lit cette ligne comme le nombre 0 suivi d'un jeton inconnu ,5, et l'opérateur cm se retrouve avec de mauvais opérandes. Rien ne se lève, rien ne se journalise. La page se contente de se rendre avec une matrice de transformation qui a dérivé, et remonter d'un dessin déplacé jusqu'à un réglage de locale est un après-midi misérable
Le second défaut se cache derrière le premier. FloatToStr utilise le format ffGeneral, qui bascule en notation exposant dès que la magnitude passe sous 1E-4, si bien qu'un minuscule décalage sortait en 1E-5. Le même §7.3.3 précise que PDF ne prend pas en charge la forme exposant, ce qui veut dire que même une machine en locale US pouvait écrire un opérande invalide avec une valeur assez petite. Les tests de régression de cette version épinglent les deux formes de défaillance : ils basculent le séparateur en virgule, appellent l'API et passent au peigne le contenu produit à la recherche de tout jeton contenant une virgule ou un exposant
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
OldSeparator: Char;
begin
Lib := TPDFlib.Create;
try
Lib.SetPageDimensions(300, 200);
Lib.DrawBox(10, 10, 20, 20, 1);
OldSeparator := FormatSettings.DecimalSeparator;
try
FormatSettings.DecimalSeparator := ','; // simule un bureau de-DE
Lib.AddPageMatrix(0.5, 0.25, 1E-5, 12.75);
// la v3.539.26 et plus écrivent : 0.5 0 0 0.25 0.00001 12.75 cm
// les builds plus anciens écrivaient : 0,5 0 0 0,25 1E-5 12,75 cm
Writeln(Lib.GetPageContentToString);
finally
FormatSettings.DecimalSeparator := OldSeparator;
end;
finally
Lib.Free;
end;
end;
Deux sortes de nombres, deux familles de helpers
La correction dans PDFlibPas est une séparation stricte : les nombres montrés à des humains peuvent suivre la locale, et les nombres écrits pour une machine ne la suivent jamais. PLFloatToStr et PLStrToFloat restent dans PDFlibExtra.pas pour le texte destiné aux utilisateurs, et leur déclaration porte maintenant un commentaire qui dit exactement ça. Tout ce qui finit en syntaxe PDF passe par PLDoubleToStrConst avec un nombre fixe de décimales choisi pour le travail : six pour les matrices, quatre pour les coordonnées et les ajustements TJ, trois pour les couleurs et les rectangles FDF. L'audit de la v3.539.26 a touché plus de sites d'appel que le rapport de bug d'origine ne le suggérait :
AddPageMatrix,ScalePageetDeskewPage, qui préfixent tous uncmau contenu existant de la page- Les constructeurs d'éléments de page qui émettent des remises à zéro
Tm, des avancéesTJet des transformationscm - Les matrices de placement de glyphes et les points de contour du convertisseur texte-vers-chemin
- La boîte de remplissage noire que
RedactRegionpréfixe, les valeurs/Rectde l'export FDF et les opérandes qu'écrit la recoloration
PLDoubleToStrConst est un formateur écrit à la main plutôt qu'un wrapper autour de FloatToStrF, et trois de ses propriétés comptent ici. Il écrit toujours une période et rogne les zéros traînants, donc 0.5 reste 0.5 au lieu de 0.500000. Il n'écrit jamais d'exposant pour une entrée finie. Et une valeur non nulle plus petite que la précision demandée garde ses chiffres significatifs au lieu de s'effondrer à zéro, donc PLDoubleToStrConst(1E-9, 6) renvoie 0.000000001 ; seules les valeurs sous environ 5E-16 deviennent 0. Cette dernière règle existe parce qu'arrondir un minuscule facteur d'échelle à zéro transforme une matrice valide en matrice singulière, ce qui est un bug pire que celui qu'on corrige
uses
PDFlibExtra;
procedure CheckNumberHelpers;
var
V: Double;
begin
// Sortie machine : décimales à point, pas d'exposant, zéros traînants rognés
Assert(PLDoubleToStrConst(1E-5, 6) = '0.00001');
Assert(PLDoubleToStrConst(-0.5, 6) = '-0.5');
Assert(PLDoubleToStrConst(0.000012346, 4) = '0.00001235'); // garde 4 chiffres significatifs
Assert(PLDoubleToStrConst(12345.25, 4) = '12345.25');
// Entrée machine : échec doux au lieu d'un EConvertError
Assert(PLTryStrToFloatInvariant('0.5', V) and (V = 0.5));
Assert(not PLTryStrToFloatInvariant('0,5', V)); // les nombres de contenu n'utilisent jamais de virgule
end;
Pourquoi le côté lecture est-il plus dangereux que le côté écriture ?
Le côté lecture est plus dangereux parce qu'un parseur lié à la locale ne produit pas un nombre faux, il lève. PLStrToFloat appelle StrToFloat, qui déclenche EConvertError quand le texte ne colle pas au séparateur système. Sur un système à décimales virgule, cela voulait dire que RecolorPage abandonnait dès qu'il rencontrait un banal opérateur 0.5 g, donc chaque page du monde réel échouait, pas seulement les exotiques. RenderPageRegionToFile rejetait son propre format de clip documenté "10.5,20.5,50.5,40.5", et les attributs de longueur SVG, les couleurs d'export SVG, les listes de sommets d'annotations et les valeurs de solidité des output intents étaient soit refusés soit remplacés en silence par des défauts. Une bibliothèque qui marche parfaitement sur la machine du développeur et échoue sur le premier client à Munich est exactement le genre de code qui, comme les cas de l'article sur le code Delphi qui marche par accident, ne paraît correct qu'à cause de l'endroit où il a été testé
La v3.539.33 a classé chaque appel à StrToFloat et TryStrToFloat selon la provenance de son entrée. Les opérandes de flux de contenu, les attributs SVG, les chaînes de couleur du painter et les listes de clip et de sommets séparées par des virgules ont tous une syntaxe à point figée, donc ils passent maintenant par PLTryStrToFloatInvariant, qui rogne le texte, le parse avec PLInvariantFormatSettings et renvoie False pour une entrée vide, mal formée ou non finie au lieu de lever. Une liste séparée par des virgules ne laisse aucune place au compromis, car une virgule ne peut pas être à la fois le délimiteur de liste et la marque décimale. La même passe a aussi corrigé une écriture hors bornes : RenderPageRegionToFile rangeait une cinquième valeur de clip au-delà de son tampon à quatre éléments. Pour le pipeline de recoloration décrit dans le guide de conversion d'un PDF vers un seul espace de couleur, le résultat pratique est que RecolorPage et RecolorDocument n'abandonnent plus sur un système à décimales virgule. Les valeurs de règles qu'un appelant tape dans CheckDocumentPolicy sont le seul cas de lecture qui emploie le helper tolérant, pour la raison que la section suivante explique
Que se passe-t-il si vous ne réparez qu'un bout d'un aller-retour ?
Ne réparer qu'un bout d'un aller-retour de locale casse du code qui marchait, voilà pourquoi le changement d'attributs de structure de la v3.539.32 a déplacé l'écrivain et le lecteur ensemble. Les wrappers SetStructElem* relaient les nombres sous forme de chaînes : SetStructElemBBox formate quatre valeurs en une chaîne, la stocke via AddTagAttribute, et l'écrivain /A parse plus tard cette chaîne pour décider si elle devient un nombre, un tableau ou un name. Les deux bouts suivaient la locale système, donc sur un système à décimales virgule l'aller-retour était auto-cohérent. Le bug n'apparaissait que si un appelant suivait la documentation et passait "0.5" à AddTagAttribute : le lecteur ne savait pas le parser et émettait le PDF name /0.5. Le placeholder PDF/VCR avait le problème en miroir, car la bibliothèque générait GTS_BBox avec un point puis le validait avec la locale avant la sauvegarde
Changer seulement l'écrivain pour un point aurait été pire que ne rien faire, puisque chaque valeur SetStructElem* aurait alors échoué au lecteur lié à la locale et dégénéré en name. Donc les écrivains emploient maintenant PLDoubleToStrConst(v, 6), et le lecteur emploie le nouveau PLTryStrToFloatLenient, qui essaie d'abord la forme à point et retombe sur la locale système. Un appelant en locale virgule qui passait "1,25" par le passé obtient toujours le nombre 1.25. Le compromis est délibéré et documenté : sur un système allemand, "1.500" devenait un name parce que StrToFloat rejette les séparateurs de milliers, et il se lit maintenant comme 1.5, tandis que les chaînes littérales NAN et INF ne sont plus acceptées comme nombres
uses
System.SysUtils, PDFlibrary;
var
Lib: TPDFlib;
begin
FormatSettings.DecimalSeparator := ','; // appelant à décimales virgule
Lib := TPDFlib.Create;
try
Lib.BeginTag('Figure', 'Sales chart', '');
Lib.SetStructElemBBox(10.5, 20.25, 200.5, 100.75); // /BBox [ 10.5 20.25 200.5 100.75 ]
Lib.AddTagAttribute('Layout', 'SpaceAfter', '0.5'); // /SpaceAfter 0.5, avant : /0.5
Lib.AddTagAttribute('Layout', 'StartIndent', '1,25'); // toujours /StartIndent 1.25
Lib.DrawText(20, 20, 'figure');
Lib.EndTag;
Lib.SaveToFile('tagged.pdf');
finally
Lib.Free;
end;
end;
Là où NaN et infini se font arrêter
AddPageMatrix, ScalePage et RedactRegion rejettent maintenant les arguments NaN et infinis d'entrée de jeu et renvoient 0, parce qu'aucun nombre PDF ne peut les représenter. ScalePage refusait déjà les facteurs zéro ou négatifs, mais NaN passe un test <= 0, donc un facteur NaN voyageait jusqu'au formateur. En v3.539.26, ce formateur appelait encore Round sur NaN, ce qui déclenche EInvalidOp en Win32 là où l'unité x87 ne masque pas les opérations invalides ; la v3.539.31 a fait écrire 0 pour NaN par PLDoubleToStrConst comme dernière ligne de défense, mais un zéro dans une matrice est une transformation singulière, donc le contrôle au niveau de l'API reste la vraie correction. Deux frontières restent en place à dessein. Les chaînes d'état des metafiles sont écrites et lues avec la locale à l'intérieur d'un seul processus et n'en sortent jamais, donc on les a laissées tranquilles. Et un test qui formate 1E-5 par le chemin des éléments de page doit lire le contenu avant que la couche soit réécrite, car réémettre des opérandes à la précision du document transforme légitimement cette valeur en 0
Si votre application part chez des clients hors du monde à décimales point, l'habitude la plus sûre est celle que la suite de tests PDFlibPas emploie maintenant : faire tourner une fois les chemins de production de PDF avec FormatSettings.DecimalSeparator réglé sur la virgule et passer la sortie au peigne à la recherche de virgules et d'exposants. L'article sur la préservation de la précision décimale parsée couvre l'autre moitié de la même histoire, la façon dont les nombres lus dans un fichier existant gardent leur texte exact à la sauvegarde. Les téléchargements, la référence API complète et le build d'essai sont sur la page produit de la PDFlibPas Delphi PDF library