Article technique

Nombres PDF face au JSON : NaN, Infinity et null en Delphi

PDF Library for Delphi (PDFlibPas) émet du JSON valide pour chaque nombre PDF depuis la v3.539.31. GetObjectJSON réécrit les jetons qu'ISO 32000-1 accepte mais que RFC 8259 rejette, tels que -.25, +1.5 et 007.5, en -0.25, 1.5 et 7.5 chiffre pour chiffre ; GetDocumentJSON et les rapports d'analyse écrivent null pour NaN et Infinity ; et PLDoubleToStr écrit 0 pour NaN au lieu de lever EInvalidOp au milieu d'un export. Avant la correction, la bibliothèque pouvait produire un JSON que son propre lecteur refusait de recharger

Pourquoi un nombre PDF valide casse-t-il le JSON ?

Parce que les deux grammaires se disputent sur quatre petits détails, et un parseur PDF qui respecte le texte source transporte ces détails directement dans la sortie. ISO 32000-1 §7.3.3 laisse un nombre commencer par un signe plus, omettre la partie entière (.5), finir sur un point nu (4.) et porter des zéros initiaux (007.5). RFC 8259 §6 n'admet rien de tout ça : un moins optionnel, une partie entière qui vaut 0 ou commence par 1 à 9, et au moins un chiffre après tout point décimal. Les producteurs sont libres d'écrire les formes PDF, et nombre de générateurs et de fichiers édités à la main le font

La fuite venait d'une fonctionnalité de précision délibérée. Depuis la v3.539.19, TPDFNumeric.Output renvoie le texte exact que le tokenizer a parsé pour les nombres réels, ce qui garde une valeur de couleur calibrée exacte à la sauvegarde, comme décrit dans la préservation de la précision décimale PDF parsée. Le tokenizer corrige déjà .5 en 0.5 et 4. en 4.0 à l'entrée, et les entiers sont reformattés depuis leur valeur, donc +3 revient en 3. Ce qui survit verbatim, c'est le reste : un point signé en tête (-.25), un plus explicite sur un réel (+1.5) et des zéros initiaux (007.5). L'ancien écrivain d'objets collait Output juste après "value":, et TJSONParser.ParseNumber dans le propre lecteur de la bibliothèque bute sur chacun d'eux avec « Invalid JSON number », si bien que l'export réussissait et le ré-import échouait avec PDFLIB_ERROR_OBJECT_JSON_INVALID (105)

PDFlibPas GetObjectJSON réécrit les jetons de nombres PDF que RFC 8259 rejette chiffre pour chiffre : -.25 devient -0.25, +1.5 perd son plus, 007.5 laisse tomber ses zéros initiaux, et les chiffres fractionnaires comme 1.250000 survivent, car un formatage depuis le Double stocké ajouterait du bruit binaire
L'ancien écrivain collait le texte parsé exact, le propre lecteur de la bibliothèque s'arrêtait avec Invalid JSON number, et l'erreur 105 cassait un aller-retour que le côté export déclarait un succès
uses
  System.SysUtils, PDFlibrary;

var
  Lib: TPDFlib;
  JSON: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile('legacy-drawing.pdf', '') = 0 then
      raise Exception.Create('load failed');

    // L'objet 12 est un tableau écrit [-.25 +1.5 007.5]
    JSON := Lib.GetObjectJSON(12, 0);
    // v3.539.31 et plus : les valeurs arrivent en -0.25, 1.5 et 7.5

    // SetObjectJSON n'accepte aucune option, donc passez 0
    if Lib.SetObjectJSON(12, JSON, 0) = 0 then
      raise Exception.CreateFmt('round trip rejected, error %d',
        [Lib.LastErrorCode]);

    Lib.SaveToFile('legacy-drawing-roundtrip.pdf');
  finally
    Lib.Free;
  end;
end;

Comment PDFNumberTextToJSON garde chaque chiffre

PDFNumberTextToJSON ré-épelle le jeton au lieu de le recalculer depuis un Double. La fonction dans PDFlibObjectJSON lit un signe optionnel, ramasse les chiffres avant et après une unique virgule décimale, puis n'applique que les retouches que JSON exige : elle jette un plus, rogne les zéros initiaux en en gardant un, fournit 0 quand la partie entière est vide, jette un point final nu et remet le moins. Un jeton contenant tout autre caractère, ou aucun chiffre du tout, retombe sur PLJSONNumber(Value, 10), qui écrit null quand la valeur n'est pas finie

PDFlibPas PDFNumberTextToJSON lit le signe, ramasse les chiffres autour d'une unique virgule décimale et n'applique que les retouches que JSON exige, tandis que tout autre caractère ou une série de chiffres vide retombe sur PLJSONNumber, qui écrit null pour NaN et Infinity au lieu d'un nombre
Ré-épeler bat recalculer : le tokenizer a déjà corrigé .5 et 4. à l'entrée, donc l'écrivain garde chaque chiffre survivant et l'aller-retour recrée exactement la même valeur
  • -.25 devient -0.25, et +.5 devient 0.5
  • +1.5 devient 1.5
  • 007.5 devient 7.5, tandis que 0.75 reste tel quel
  • 4. devient 4 si un tel jeton atteint jamais l'écrivain
  • 2.22221 et 1.250000 gardent chaque chiffre fractionnaire, zéros finaux compris

Formater depuis le Double stocké aurait été plus court et faux, pour la même raison qui a motivé la correction de précision : la précision de sortie par défaut est de quatre décimales, et même une conversion pleine précision peut ajouter du bruit binaire à un littéral décimal. Garder les chiffres veut dire que SetObjectJSON et ImportObjectJSON, qui remettent chaque texte de nombre JSON au tokenizer PDF, recréent exactement la même valeur. La garantie porte sur la valeur, pas sur les octets : après un ré-import, -.25 est stocké et sauvegardé en -0.25. Les deux graphies sont égales sous §7.3.3, mais un diff au niveau des octets signalera le changement, donc ne traitez pas un cycle export-import comme un no-op sur un document dont les octets sont couverts par une signature

Que devient un nombre que JSON ne peut pas représenter ?

GetDocumentJSON écrit maintenant null pour tout nombre NaN ou infini, parce que RFC 8259 §6 n'a de syntaxe pour ni l'un ni l'autre. Infinity est plus facile à produire qu'il n'y paraît : le tokenizer PDF accumule les chiffres par multiplications répétées dans un Double, qui plafonne près de 1.8 × 10308, si bien qu'un littéral entier un peu au-delà de 300 chiffres devient silencieusement +Inf. Les fichiers honnêtes ne contiennent jamais un tel littéral ; les fichiers fuzzés et hostiles, si, voilà pourquoi ils ont leur place dans le même corpus de tests que les cas de durcissement d'un parseur PDF Pascal contre les fichiers malveillants. L'ancien écrivain de documents formatait les non-entiers avec Str(D:0:6), et pour +Inf ça écrit le texte +Inf, qu'aucun consommateur JSON ne parsera

Le null est délibérément avec perte. Les consommateurs de la sortie de GetDocumentJSON doivent accepter null partout où un nombre peut apparaître, et doivent le lire comme « une valeur était présente mais ne peut pas être représentée », pas comme une clé absente. Le littéral d'origine n'est pas récupérable depuis le JSON du document, donc un pipeline qui y tient devrait journaliser l'objet et traiter le fichier comme suspect plutôt que de substituer un défaut

Pourquoi un seul NaN pouvait-il avorter un export SVG ou JSON ?

Parce que PLDoubleToStr, le formateur de nombres invariant derrière les flux de contenu, le SVG, le XML, le CSV et la plupart du JSON de la bibliothèque, mettait son entrée à l'échelle et appelait Round, et Round(NaN) lève EInvalidOp sur des cibles comme Win32, où Delphi laisse l'exception invalid-operation du x87 démasquée. L'exception partait après que l'écrivain avait déjà émis une partie de sa sortie, si bien qu'une seule mesure dégénérée, un 0/0 dans une métrique ou un NaN passé par un appelant, laissait derrière elle un fichier tronqué. PLDoubleToStr renvoie maintenant 0 pour NaN, et sa branche entière se borne à ±9.2e18 comme la branche fractionnaire, si bien qu'Infinity ressort aussi en littéral fini

Zéro est la bonne réponse pour un flux de contenu, où un emplacement de nombre doit tenir un nombre, et la mauvaise réponse pour un rapport, où 0 est une mesure plausible. Les écrivains JSON qui doivent garder la différence emploient PLJSONNumber(Value, Decimals) de PDFlibExtra, qui écrit null pour NaN ou Infinity et des chiffres invariants sinon. PLJSONNumber porte maintenant GetSimilarImageDeduplicationReportJSON, GetAnnotationHitsJSON et les rapports barcode, deskew, texte structuré et PDF/VCR ; le rapport deskew écrivait autrefois 0 pour un angle non fini et écrit maintenant null

PDFlibPas arrête NaN et Infinity de trois façons : AddPageMatrix, ScalePage et RedactRegion rejettent les arguments non finis d'entrée de jeu, PLDoubleToStr écrit 0 dans les emplacements de flux de contenu, et PLJSONNumber écrit null dans les rapports, où zéro se lirait comme une mesure plausible, depuis que Round(NaN) levait EInvalidOp en plein export
Zéro est la bonne réponse pour un flux de contenu et la mauvaise pour un rapport, donc les écrivains de rapports remettent chaque Double à PLJSONNumber et laissent null dire que la valeur était présente mais non représentable
uses
  SysUtils, PDFlibTypes, PDFlibExtra;

function SkewReportJSON(Page: Integer; Angle, Confidence: Double): string;
var
  B: PLStringBuilder;
begin
  B := PLStringBuilder.Create(128);
  try
    // Formatez chaque Double en texte d'abord ; PLJSONNumber écrit null
    // pour NaN ou Infinity et emploie toujours un point décimal
    B.Append('{"page":').Append(Page)
     .Append(',"angle":').Append(string(PLJSONNumber(Angle, 4)))
     .Append(',"confidence":').Append(string(PLJSONNumber(Confidence, 4)))
     .Append('}');
    // Jamais B.Append(Angle) : la surcharge Double suit la locale de l'utilisateur
    Result := B.ToString;
  finally
    B.Free;
  end;
end;

Où la locale de l'utilisateur se faufile-t-elle encore dans le JSON ?

Par n'importe quel formateur qui consulte les paramètres régionaux, et un audit complet de la sortie lisible par machine n'en a trouvé qu'un seul restant : maxAcceptedMeanError dans GetSimilarImageDeduplicationReportJSON, écrit avec PLFloatToStr, un fin wrapper sur FloatToStr. Sur un bureau dont le séparateur décimal est une virgule, le rapport contenait "maxAcceptedMeanError":1,5, qu'un parseur JSON lit comme la valeur 1 suivie d'un jeton égaré. Le champ rapporte la pire erreur de pixel acceptée venant de la déduplication perceptuelle d'images, et il passe maintenant par PLJSONNumber(Stats.MaxAcceptedMeanError, 6). Un piège restant est PLStringBuilder : sous Delphi c'est un simple alias de System.SysUtils.TStringBuilder, dont la surcharge Append(Double) formate via la locale de l'utilisateur, tandis que les builds FPC emploient une classe de la bibliothèque à la place, donc un test sous Free Pascal ou sur une machine en-US ne l'attrapera jamais

uses
  System.SysUtils, System.JSON, PDFlibrary;

var
  Lib: TPDFlib;
  Report: WideString;
  Parsed: TJSONValue;
begin
  // Reproduisez un bureau allemand ou français dans le run de test
  FormatSettings.DecimalSeparator := ',';
  Lib := TPDFlib.Create;
  try
    // Utilisez une fixture qui contient vraiment des images quasi dupliquées,
    // sinon l'erreur moyenne vaut 0 et le bug reste caché
    Lib.LoadFromFile('scanned-batch.pdf', '');
    // Dry run avec seuils 2, 2, 4 : le document n'est pas modifié
    Lib.GetSimilarImageDeduplicationReportJSON(2, 2, 4, Report);
    Parsed := TJSONObject.ParseJSONValue(Report);
    if Parsed = nil then
      raise Exception.Create('report is not valid JSON on a comma locale');
    Parsed.Free;
  finally
    Lib.Free;
  end;
end;

Une suite de régression pour du JSON a besoin de trois fixtures pour rester honnête : une page portant -.25, +1.5 et 007.5, un objet tenant un entier de 400 chiffres, et n'importe quel rapport tourné sous une locale à virgule, chacun validé avec un parseur strict plutôt qu'à l'œil. Le JSON d'objets, le JSON de documents et les rapports d'analyse de PDF Library for Delphi partagent les mêmes règles de nombres sous Delphi, C++Builder et Free Pascal ; la liste complète des fonctionnalités est sur la page produit de PDF Library for Delphi