Article technique

Formatage invariant des nombres PDF en Delphi avec HotPDF

Chaque nombre réel qui atteint un fichier PDF doit être écrit avec un point, quoi que pense le système d'exploitation du séparateur décimal. Le HotPDF Delphi Component route tous à travers une seule couche de conversion invariante dans HPDFTypes, donc un poste de travail allemand, français ou brésilien émet 0.5 0 0 0.5 5.5 7.25 cm et pas la variante à virgules qu'aucun lecteur conforme ne parse

Ça sonne comme une correction de deux lignes. Ça ne l'était pas. Le défaut avait été camouflé pendant des années par un effet de bord, il réapparaissait dans trois sous-systèmes que personne ne considère comme du code de formatage de nombres, il disparaissait sous un compilateur et survivait sous un autre, et la dernière couche n'avait rien à voir avec le séparateur décimal

Pourquoi une locale à virgule décimale corrompt-elle un flux de contenu PDF ?

Parce que ISO 32000-1 §7.3.3 définit un objet numérique comme un signe optionnel, des chiffres décimaux, et au plus un point. Une virgule ne fait pas partie de cette production, et sous §7.2.2 ce n'est pas non plus un délimiteur, donc le lecteur ne voit pas deux opérandes là où vous en vouliez un. Il voit un jeton qui échoue au lexing, et l'opérateur qui le contenait est jeté. Un seul 0,5 dans une matrice d'état graphique emporte tout le bloc q ... Q autour de lui. L'échec a aussi une forme distinctive sur le terrain : il ne se reproduit jamais sur la machine du développeur, il arrive comme ticket de support depuis un seul pays, et il se lit comme un bug de rendu plutôt qu'une erreur de syntaxe, parce que la plupart des visionneuses laissent tomber l'opérateur malformé silencieusement et rendent le reste de la page

Diagramme de la corruption de locale dans un flux de contenu PDF produit par HotPDF : un processus hôte avec une virgule décimale émet 0,5 dans un opérateur rg et un opérateur cm, la grammaire de nombres d'ISO 32000-1 n'accepte que le point donc le jeton échoue au lexing, et la visionneuse jette silencieusement les opérateurs avec le bloc q Q autour d'eux
Une virgule ne fait pas partie de la production numérique et n'est pas un délimiteur, donc un opérande malformé retire le rectangle de redaction et la page stitchée sans aucune erreur
// Processus hôte tournant avec DecimalSeparator = ','
Doc.LoadFromFile('invoice.pdf');
Doc.RedactLoadedRect(0, 10.25, 20.5, 40.75, 60.5, 0.5, 0.25, 0.125);
Doc.StitchLoadedPage(0, 1, 5.5, 7.25, 0.5);

// Ce que le /Contents ajouté avait l'habitude de porter :
//   0,5 0,25 0,125 rg 10,25 20,5 30,5 40 re f
//   q 0,5 0 0 0,5 5,5 7,25 cm /StitchSrc Do Q
//
// ISO 32000-1 7.3.3 n'a pas de production pour « 0,5 », donc le rectangle
// de redaction et la page stitchée disparaissent tous deux sans erreur

L'effet de bord qui a caché le défaut pendant des années

HotPDF écrit la plupart des nombres du chemin de création via _CutFloat, et les premières révisions de cette fonction faisaient quelque chose d'hostile en silence : à chaque appel, elle assignait '.' au FormatSettings.DecimalSeparator global du processus hôte. Cela corrompt l'état de locale de l'application qui héberge le composant et ce n'est pas thread safe, mais la conséquence intéressante est la troisième. Une fois qu'un contenu avait été généré, le séparateur du processus était un point, donc chaque FloatToStr et StrToFloat nu ailleurs dans la bibliothèque devenait accidentellement correct. L'ordre créer-puis-éditer que presque chaque exemple utilise passait donc, et ce qui était masqué, c'était chaque chemin qui s'exécute avant le premier appel à _CutFloat : les éditeurs de document chargé StitchLoadedPage et RedactLoadedRect, les tampons de texte et d'image, les filigranes composites, le dessin de code-barres via Format('%.2f'), le parseur d'apparence par défaut /DA, et tout le sous-système de polices Type 1

Chronologie de l'effet de bord _CutFloat dans HotPDF : le premier appel assigne un point au FormatSettings.DecimalSeparator global, ce qui rend chaque FloatToStr nu ultérieur accidentellement correct pendant que les éditeurs de chargés StitchLoadedPage et RedactLoadedRect, le chemin code-barres, le parseur /DA et le sous-système de polices Type 1 restent cassés
L'ordre créer-puis-éditer que chaque exemple utilise gardait le défaut invisible, parce que tout ce qui suit la première conversion héritait du séparateur global basculé

Le parsing Type 1 échouait de la plus laide des façons. FontMatrix, BlueValues, les stem hints, et les nombres réels dans les charstrings Type 2 sont des réels PostScript écrits avec des points, selon la spécification Adobe Type 1 Font Format. Parser 0.001 sous une locale à virgule lève EConvertError, un try/except englobant l'avalait, et la matrice de police restait à zéro. Chaque glyphe de cette police s'effondrait alors, ce qui ressemble à un problème de police plutôt qu'un problème de locale et vous envoie déboguer le rendu des glyphes embarqués pendant un après-midi

Une couche de conversion unique au lieu de rustines éparpillées

La règle que HotPDF suit maintenant est brutale : tout nombre écrit dans ou lu depuis la syntaxe PDF, PostScript, JSON ou JavaScript passe par une conversion invariante explicite, et aucun code nulle part ne dépend d'un effet de bord global FormatSettings. HPDFTypes possède les quatre conversions, plus l'enregistrement de réglages qu'elles partagent et un helper à précision fixe pour le chemin code-barres

// Lib/HPDFTypes.pas - la surface de conversion invariante unique
function HPDFInvariantFormatSettings: TFormatSettings;

function HPDFFloatToInvariantStr(Value: Extended): string;
function HPDFTryInvariantStrToFloat(const S: string;
  out Value: Extended): Boolean;
function HPDFInvariantStrToFloat(const S: string): Extended;
function HPDFInvariantStrToFloatDef(const S: string;
  const Default: Extended): Extended;

function HPDFFormatInvariant2(Value: Extended): string;

Deux unités portaient déjà une fonction privée InvariantFormatSettings qui leur était propre, HPDFContentStream et HPDFTextExtraction, et les deux ont été réduites à un forward d'une ligne plutôt que laissées comme implémentations parallèles. Le sous-système Type 1 est l'exception délibérée : il ne porte aucune dépendance VCL et ne référence pas HPDFTypes, donc il garde un miroir local nommé HPDFType1Core.Type1StrToFloat avec des sémantiques identiques, sur la théorie qu'une fonction de dix lignes dupliquée est un passif plus petit que de traîner la couche graphique dans un parseur de polices. Deux autres endroits ont été laissés seuls exprès. HPDFHTMLImport parse les longueurs CSS en échangeant délibérément le point contre le séparateur de locale puis en parsant avec la locale, ce qui est juste pour cette entrée, et HPDFType1CB.GlyphCommands avec les chaînes FontBBox est une API publique que les appelants relisent avec leur propre StrToFloat, où produire et consommer dans la même locale est auto-cohérent et où une sortie invariante casserait chaque appelant en région à virgule

Que garantit HPDF_HAS_FORMAT_SETTINGS à travers les compilateurs ?

Il garantit que le chemin invariant compile réellement dans chaque chaîne d'outils supportée, ce que la porte de version évidente ne faisait pas. Delphi XE2 a introduit TFormatSettings.Create et les surcharges FloatToStr / TryStrToFloat qui prennent un TFormatSettings, donc le premier instinct est d'écrire {$IFDEF XE2+}, InvariantFormatSettings{$ENDIF} à chaque site d'appel. Cet instinct a un angle mort : HotPDF.inc définit D2009+ sous Free Pascal mais ne définit jamais XE2+, parce que XE2+ répond à une question sur les numéros de version Delphi, pendant que Free Pascal 3.2.2 livre ces surcharges quoi qu'il arrive. Chacune de ces portes dégradait donc vers la branche dépendante de la locale dans les builds FPC, silencieusement, sur exactement les plateformes d'où venaient les rapports originaux. HPDF_HAS_FORMAT_SETTINGS est défini dans le bloc FPC et le bloc XE2+ de HotPDF.inc, et c'est le seul symbole que les helpers de nombres testent

{$IFDEF HPDF_HAS_FORMAT_SETTINGS}
function HPDFInvariantFormatSettings: TFormatSettings;
begin
{$IFDEF FPC}
  Result := DefaultFormatSettings;   // pas de TFormatSettings.Create dans FPC
{$ELSE}
  Result := TFormatSettings.Create;
{$ENDIF}
  Result.DecimalSeparator := '.';
  Result.ThousandSeparator := ',';
end;
{$ENDIF}

Une branche de compilateur survit à l'intérieur de la fonction, parce que Free Pascal n'a pas de TFormatSettings.Create et qu'il faut partir de DefaultFormatSettings. Seuls les deux séparateurs de nombres alimentent les conversions, donc les champs de date et de devise gardent ce que la locale courante fournit. Le Delphi pré-XE2 n'a aucune surcharge du tout, et là les helpers retombent sur une conversion avec la locale du processus puis réécrivent le caractère séparateur dans la chaîne résultante, ce qui est moche mais ne touche jamais l'état global

La troisième couche : les exposants que le SVG permet et que le PDF ne permet pas

Corriger le séparateur décimal est nécessaire et pas suffisant. W3C SVG 1.1 §4.2 admet un exposant dans sa production de nombres et le balisage XPS fait pareil, donc 1E-6 est une coordonnée parfaitement valide à l'entrée, pendant qu'ISO 32000-1 §7.3.3 n'admet aucun exposant. Une valeur qui survit au formatage invariant peut encore atterrir dans un flux de contenu comme 1E-6 et être rejetée, et FloatToStrF en format fixe peut lui-même retomber en forme exponentielle une fois que la valeur sort de la plage de chiffres de la RTL. HPDFSafeSVG.SVGFloat fait donc son propre travail après la conversion invariante : il rabat tout ce qui est sous 1.0E-7 à zéro, puis, si un E survit, retire l'exposant et déplace le point décimal à la main, en rembourrant de zéros du côté que le décalage exige. SVGFinite garde l'entrée en rejetant NaN et tout ce qui est hors de ±1.0E30 avant que ça atteigne le formateur, et SVGReadNumber avec SVGSkipSeparators gère la direction entrante caractère par caractère au lieu de s'en remettre à la RTL

Pipeline montrant comment HotPDF retire les exposants avant d'écrire des opérandes PDF : SVGFinite rejette NaN et les valeurs au-delà de 1.0E30, la conversion invariante laisse un point, les petites magnitudes sont rabattues à zéro, et la sortie HPDFFloatToInvariantStr avec un E survivant a l'exposant retiré et le point décimal déplacé à la main
SVG 1.1 admet un exposant et ISO 32000-1 non, donc la frontière de conversion réécrit 1E-6 en notation décimale ordinaire qu'un lecteur conforme peut parser
// Lib/HPDFSafeSVG.pas - notation décimale pour les opérandes PDF
if Abs(Value) < 0.0000001 then
  Value := 0;
S := HPDFFloatToInvariantStr(Value);
ExponentAt := Pos('E', UpperCase(S));
if ExponentAt > 0 then
begin
  ExponentValue := StrToInt(Copy(S, ExponentAt + 1, MaxInt));
  SetLength(S, ExponentAt - 1);
  // ... décale le point décimal de ExponentValue, en rembourrant de '0'
  // à gauche ou à droite, puis réattache le signe
end;

C'est le même TSVGBuffer qui alimente l'export SVG de page et sa gestion d'état graphique, et la même discipline numérique s'applique côté import quand on convertit des documents XPS et OXPS en PDF via HPDFCreateXPSDocumentHandler et le membre hdfXPS de THPDFHandledDocumentFormat. Les formats de balisage sont généreux sur la syntaxe des nombres. Le flux de contenu PDF ne l'est pas, et c'est à la frontière de conversion que cette différence doit être résolue

Des tests de régression qui reproduisent la locale au lieu de la supposer

Un bug de locale qui n'apparaît que sur la machine de quelqu'un d'autre vaut d'être épinglé par des tests qui règlent la locale eux-mêmes, et HotPDF en porte deux. LoadedEditsEmitInvariantNumbersUnderCommaLocale assigne une virgule à FormatSettings.DecimalSeparator avant la première édition, exécute RedactLoadedRect, StampLoadedPageText, StitchLoadedPage et un tampon composite, puis relit les octets de contenu de la page et affirme à la fois que les chaînes d'opérateur exactes sont présentes et que le buffer ne contient aucune virgule nulle part. DocumentCreationEmitsInvariantNumbersAndKeepsHostLocale couvre l'autre moitié et le péché originel : il pilote le chemin de création sous la même locale à virgule, y compris DirectDrawBarcode avec son formatage fixe à deux décimales, puis affirme que le flux de contenu ne porte aucune virgule et que FormatSettings.DecimalSeparator est toujours une virgule quand EndDoc revient

Cette seconde assertion est celle qui aurait attrapé le défaut original le jour où il a été écrit, parce qu'elle teste ce que la bibliothèque fait à son hôte plutôt que seulement ce qu'elle écrit sur disque. La leçon se généralise au-delà du PDF : tout format avec une grammaire lexicale fixe, PostScript, JSON et JavaScript inclus, a besoin d'une conversion invariante explicite à la frontière. Tendre la main vers un réglage global pour avoir la bonne réponse marche jusqu'à ce que quelqu'un appelle votre code dans un ordre différent, sur un thread différent, ou depuis un compilateur différent, et à ce moment l'échec ressemble à tout sauf à un problème de formatage

La couche de formatage de nombres décrite ici est livrée comme partie du HotPDF Delphi Component pour Delphi et C++Builder, avec les chemins d'édition de document chargé, de code-barres, d'export SVG et de conversion XPS qui en dépendent