Article technique

CalRGB /Gamma en Delphi : préserver le texte décimal lu

PDFlibPas, la losLab PDF Developer Library, conserve le texte décimal exact qu'il a analysé pour chaque nombre réel d'un document et réécrit ce texte tel quel dès que la valeur n'a jamais été modifiée. Depuis la v3.539.19, le réglage SetPrecision ne gouverne plus que les nombres que la bibliothèque crée ou modifie, donc un banal chargement-enregistrement n'arrondit plus un /Gamma CalRGB de 2.22221 à 2.2222 et ne décale plus les couleurs d'une page que personne n'a touchée. Le changement est petit en code et lourd en ce qu'il dit des analyseurs : la valeur que vous décodez et le littéral que vous émettez sont deux choses différentes, et un aller-retour par Double n'est pas une transformation identité

Pourquoi une sauvegarde sans modification décalait-elle les couleurs de la page ?

Parce que ce sont les paramètres de l'espace de couleur qui étaient reformatés, pas l'image. Le fichier qui a révélé cela est un document de bureau de 35 pages du corpus de régression local, avec une image d'en-tête réutilisée sur chaque page. Le charger puis l'enregistrer tel quel produisait des flux d'image identiques octet pour octet à l'entrée, et une comparaison de hachages de flux déclarait le document inchangé. Une comparaison de rendu n'était pas d'accord : chacune des 35 pages montrait des différences de pixels dans l'en-tête, et nulle part ailleurs

L'image d'en-tête se dessine via un espace de couleur CalRGB, qu'ISO 32000-1 §8.6.5.3 définit par un /WhitePoint, un tableau /Gamma facultatif de trois éléments et une /Matrix facultative de neuf éléments. Ces tableaux sont de simples objets numériques dans le dictionnaire d'espace de couleur. TPDFNumeric stockait chacun comme un Double et rien d'autre, et TPDFNumeric.Output formatait ce Double via PDFPrecNum, qui vaut quatre décimales par défaut. Donc /Gamma passait de 2.22221 à 2.2222, une entrée de matrice passait de 0.71519 à 0.7152, et le moteur de rendu produisait fidèlement des couleurs légèrement différentes à partir d'un calibrage légèrement différent. Les octets de l'image étaient innocents ; les nombres autour ne l'étaient pas. Le plus inconfortable est à quel point cela était invisible. Comparer les octets des flux décodés ne peut pas le voir, parce que les nombres vivent dans un dictionnaire, pas dans un flux. Comparer les charges des pièces jointes ne peut pas le voir non plus. Même le diff de révision décrit dans l'article sur les niveaux de modification calcule l'empreinte d'un corps d'objet normalisé, donc les deux révisions donnent le même hachage et le diff les déclare identiques. Seul le rendu l'a attrapé, et c'est pourquoi la référence du corpus rend chaque page au lieu de se fier aux seuls contrôles structurels

Où PDFlibPas perdait la précision CalRGB lors d'une sauvegarde sans modification en Delphi : le /Gamma analysé 2.22221 et une entrée de matrice 0.71519 vivent dans TPDFNumeric sous forme de Double, Output les formate via PLDoubleToStr avec PDFPrecNum à quatre décimales, tous les contrôles structurels déclarent le document inchangé, et seule la comparaison de rendu montre les 35 images d'en-tête décalées
Les octets de l'image étaient innocents : TPDFNumeric reformatait les nombres de calibrage autour d'eux via PDFPrecNum, si bien que les hachages de flux et le diff d'empreintes déclaraient des révisions identiques pendant que le moteur de rendu produisait des couleurs légèrement différentes sur chaque page

La valeur que vous avez analysée n'est pas le littéral que vous devez écrire

Un nombre réel PDF est une chaîne décimale, et ISO 32000-1 §7.3.3 est explicite : ce n'est qu'une chaîne décimale, pas de notation de base, pas de forme exponentielle. L'annexe C liste ensuite la précision qu'une implémentation est censée honorer, environ cinq chiffres décimaux significatifs dans la partie fractionnaire. Une précision de sortie par défaut de quatre est déjà en dessous, et cela empire près de zéro : PLDoubleToStr met la valeur à l'échelle, arrondit à un entier et émet 0 quand le résultat est nul, donc une entrée de matrice valant -0.000012345 ne perd pas un chiffre, elle disparaît complètement

Relever la valeur par défaut ne ferait que déplacer la falaise. Le correctif consiste à cesser de faire comme si un Double était le nombre. Quand le tokenizer de TPDFStructure.Decode reconnaît un réel standard, c'est-à-dire un token contenant un point décimal et aucun marqueur d'exposant, il stocke le texte source dans le nouveau champ FOriginalText à côté de la valeur convertie. Output préfère alors ce texte et ne retombe sur le formatage que quand il n'y a rien à préférer

Comment PDFlibPas préserve le texte décimal analysé en Delphi : le tokenizer de TPDFStructure.Decode garde le littéral source dans FOriginalText pour tout token avec un point décimal et sans exposant, Output écrit ce texte tel quel au lieu d'appeler PLDoubleToStr, SetTo le vide parce qu'un nombre modifié est un nouveau nombre
La valeur que vous décodez et le littéral que vous émettez sont deux choses différentes : préférer le texte analysé garde 2.22221 exact, tandis que les nombres créés et modifiés par la bibliothèque suivent toujours PDFPrecNum et que le réglage n'atteint jamais l'entrée non touchée
// Lib/PDFlibStruct.pas — tout le correctif côté sortie
Function TPDFNumeric.Output: AnsiString;
Begin
  If FOriginalText<> '' Then
    Result:= FOriginalText
  Else
    Result:= PLDoubleToStr(FValue, Owner.PDFPrecNum);
End;

Procedure TPDFNumeric.SetTo(Const Value: Double);
Begin
  FOriginalText:= '';   // un nombre modifié est un nouveau nombre
  FValue:= Value;
  FChanged:= True;
End;

Deux frontières sont délibérées. Les entiers ne sont pas préservés, parce que le formatage des entiers est déjà sans perte. Les formes exponentielles comme 6.02E23 sont tolérées à l'entrée pour ménager les producteurs défaillants mais ne sont pas préservées en sortie, puisque les réécrire perpétuerait une syntaxe que §7.3.3 interdit ; elles passent par le formateur comme n'importe quel nombre généré par la bibliothèque. Le tokenizer applique aussi sa réparation minimale habituelle avant de stocker le texte, donc un littéral à point initial comme .5 est conservé sous 0.5 et un littéral à point final comme 5. sous 5.0. Les deux sont le même nombre pour tout lecteur et sont bien plus largement acceptés

Que garantit SetPrecision après la v3.539.19 ?

TPDFlib.SetPrecision contrôle désormais les décimales des nombres que la bibliothèque produit elle-même : les valeurs dessinées via le painter, les nombres créés à partir d'un Double comme via NewNumeric, et toute valeur analysée qui a été modifiée depuis avec SetTo. Notez que le texte décodé via l'API objet, par exemple un littéral passé à SetObjectFromString, traverse le même tokenizer et est préservé de la même façon. Un décimal analysé qui n'a jamais été modifié garde sa précision d'entrée quel que soit le réglage, et changer le réglage après le chargement ne le touche pas rétroactivement. L'entrée de référence de SetPrecision a été mise à jour dans la même version pour dire exactement cela, car l'ancienne formulation laissait entendre que le réglage s'appliquait à tous les nombres du fichier

L'effacement se produit dans SetTo plutôt que d'être dérivé du drapeau Changed, et cette distinction compte. Le pipeline de sauvegarde remet Changed à zéro sur les objets une fois qu'ils ont été écrits, donc un contrôle du type « émettre le texte d'origine sauf si modifié » se mettrait à émettre du texte périmé pour une valeur modifiée, enregistrée puis modifiée à nouveau dans la même session. Lier le texte d'origine à l'affectation elle-même rend impossible que les deux se contredisent. Le test de régression verrouille chacun de ces comportements avec les valeurs du fichier d'origine

uses
  PDFlibStruct;

var
  Structure: TPDFStructure;
  Values: TPDFArray;
  Number: TPDFNumeric;
begin
  Structure := TPDFStructure.Create;
  try
    Structure.PDFPrecNum := 4;
    Values := TPDFArray(Structure.Decode('[2.22221 0.71519 -0.000012345 1 0.12567]'));
    // L'entrée non modifiée survit telle quelle, y compris la valeur que
    // le formatage à quatre décimales aurait réduite à 0
    Assert(Values.Output = '[ 2.22221 0.71519 -0.000012345 1 0.12567 ]');

    // Une modification jette le texte d'origine et suit PDFPrecNum
    Number := TPDFNumeric(Values.Item[0]);
    Number.SetTo(0.123456);
    Assert(Number.Output = '0.1235');
    Assert(Structure.NewNumeric(0.123456).Output = '0.1235');

    // Abaisser la précision ensuite n'atteint pas l'entrée non modifiée
    Structure.PDFPrecNum := 2;
    Assert(TPDFNumeric(Values.Item[1]).Output = '0.71519');
  finally
    Structure.Free;
  end;
end;

Pourquoi le modèle de contenu normalise-t-il encore les nombres ?

Parce que TPDFContentProgram promet des opérandes numériques canoniques, et que cette promesse vaut plus que le texte tel quel à l'intérieur d'un flux de contenu. Le modèle de contenu éditable, celui-là même sur lequel est construit le suivi d'état graphique, existe pour que NormalizeContentStreams, l'optimiseur et Emit produisent une sortie stable et comparable à partir d'une entrée quelconque. Si un opérande analysé transportait son texte d'origine dans le modèle, une séquence d'opérateurs comme 0.50000 0 0 RG s'émettrait différemment de 0.5 0 0 RG, et toutes les comparaisons en aval dériveraient avec les habitudes de formatage du producteur

Le modèle retire donc le texte d'origine à ses deux points d'entrée. NormalizeContentNumbers s'exécute sur chaque opérande quand l'analyseur l'empile, et de nouveau dans SetOperand quand la source fournie par l'appelant est décodée, et il descend récursivement dans les tableaux et les dictionnaires pour que les motifs de tirets, les tableaux TJ et les dictionnaires de propriétés du contenu balisé soient couverts. Appeler SetTo(AsDouble) sur chaque numérique suffit, puisque c'est précisément l'opération qui efface le texte. Les données d'image inline brutes sont laissées tranquilles, comme elles l'ont toujours été

Pourquoi le modèle de contenu PDFlibPas normalise encore les nombres : NormalizeContentNumbers s'exécute là où l'analyseur empile chaque opérande et de nouveau dans SetOperand, descend récursivement dans les tableaux et dictionnaires pour couvrir les motifs de tirets, les tableaux TJ et les dictionnaires de propriétés du contenu balisé, et SetTo AsDouble efface le texte d'origine pour que 0.50000 et 0.5 s'émettent à l'identique
Des opérandes numériques canoniques, c'est la promesse du modèle de contenu : les données d'image inline brutes sont laissées tranquilles, et les nombres de dictionnaire non touchés hors des flux de contenu gardent la garantie du texte tel quel, si bien qu'un simple couple LoadFromFile et SaveToFile les préserve encore
// Lib/PDFlibContentModel.pas — le modèle de contenu garde son contrat
Procedure NormalizeContentNumbers(Obj: TPDFObject);
Var
  K: Integer;
Begin
  If Obj is TPDFNumeric Then
    TPDFNumeric(Obj).SetTo(TPDFNumeric(Obj).AsDouble)
  Else If Obj is TPDFArray Then
    For K:= 0 To TPDFArray(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFArray(Obj).Item[K])
  Else If Obj is TPDFDictionary Then
    For K:= 0 To TPDFDictionary(Obj).Count- 1 Do
      NormalizeContentNumbers(TPDFDictionary(Obj).Entry[K].Value);
End;

La règle pratique pour les appelants est donc simple. Un LoadFromFile suivi d'un SaveToFile laisse les flux de contenu non touchés et les nombres de dictionnaire non touchés tels qu'ils étaient. Une page qui passe par NormalizeContentStreams, ou toute modification faite via le modèle de contenu, ressort canonique par conception, et le reste du document est toujours préservé. Ce sont deux demandes différentes, et elles font désormais deux choses différentes

Ce que cela coûte, et où la garantie s'arrête

Chaque TPDFNumeric porte maintenant une référence AnsiString de plus, et chaque décimal analysé garde son texte source vivant aussi longtemps que l'objet. Sur un document à millions de nombres réels, c'est de la mémoire réelle, et cela a sa place dans toute mesure sur gros documents plutôt que d'être balayé d'un revers de main. La garantie est aussi limitée au document du nombre lui-même : copier des objets entre documents ou reconstruire des valeurs via l'API objet produit de nouveaux nombres, qui suivent la précision de sortie comme n'importe quel autre nouveau nombre. Il vaut la peine d'être précis sur ce que la version affirme et sur ce qu'elle n'affirme pas. Un chargement-enregistrement d'un document non touché préserve désormais les nombres de calibrage que le moteur de rendu consomme réellement, ce qui est la propriété que vérifie la référence du corpus. Elle ne prétend pas produire une sortie identique octet pour octet, ce qui dépend aussi de la numérotation des objets, de la compression des flux et de l'identifiant de trailer traité dans l'article sur l'ID PDF déterministe. Et elle ne fait pas voir au diff d'empreintes les différences d'arrondi des fichiers produits par d'autres logiciels, puisque ceux-ci hachent toujours le corps normalisé. La leçon se généralise bien au-delà de CalRGB : quand un analyseur ne garde que la valeur convertie, chaque sauvegarde est une modification, et le seul moyen de s'en apercevoir est de regarder le résultat rendu. Le traitement des nombres et la sémantique de SetPrecision sont documentés sur la page produit losLab PDF Developer Library