Article technique

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

Chargez un PDF calibré, ne changez rien, sauvegardez-le sous un nouveau nom et comparez le dictionnaire d'espace de couleur : /Gamma [2.22221 2.22221 2.22221] est entré, /Gamma [2.2222 2.2222 2.2222] est sorti. L'écrivain a reformaté un nombre qu'il n'avait jamais à toucher, et la page se rend maintenant dans des couleurs légèrement différentes. Le remède est de traiter chaque décimal parsé comme deux choses à la fois, une valeur pour l'arithmétique et un littéral pour la sortie, et de continuer à émettre le littéral jusqu'à ce que quelqu'un assigne réellement une nouvelle valeur

Pourquoi une sauvegarde sans modification peut décaler les couleurs CalRGB

Les pixels bougent parce que le renderer reçoit des données de calibration légèrement différentes alors que les octets de l'image restent identiques. ISO 32000-1 §8.6.5.3 définit un espace de couleur CalRGB par un /WhitePoint obligatoire, un tableau /Gamma à trois éléments facultatif et un /Matrix à neuf éléments facultatif, et tous sont de simples objets numériques dans le dictionnaire d'espace de couleur. Faites-les passer par un Double et un formateur fixé à quatre décimales, et 2.22221 devient 2.2222 tandis qu'une entrée de matrice de 0.71519 devient 0.7152. Une valeur gamma est un exposant appliqué à chaque composante de chaque pixel qui passe par cet espace, donc le changement apparaît partout où l'espace est utilisé

Pour un fichier qui déclare un espace CalRGB pour une image d'en-tête réutilisée sur 35 pages, les 35 en-têtes se rendent avec des couleurs décalées et rien d'autre ne change sur ces pages. Les contrôles qu'on dégaine en premier reviennent propres. Un hachage des octets de flux décodés compare les données d'image, et les données d'image sont vraiment identiques ; le dégât se trouve dans un dictionnaire. Le diff de révisions décrit dans l'article sur le niveau de modification prend l'empreinte d'un corps d'objet normalisé, donc les deux révisions y hachent à la même valeur aussi. C'est une comparaison de pages rendues qui l'attrape. Pour tout pipeline qui réécrit des PDF produits par d'autres logiciels, nous mettrions une comparaison de rendu par page avant chaque contrôle structurel

Comment un modèle numérique en Double seul perd la précision CalRGB lors d'une sauvegarde sans modification en Delphi : le /Gamma 2.22221 parsé et une entrée de matrice 0.71519 sont stockés en Double et formatés à quatre décimales en sortie, chaque contrôle structurel déclare le document inchangé, et seule la comparaison des rendus montre les 35 images d'en-tête décalées
Les octets de l'image sont innocents : reformater à précision fixe les nombres de calibration qui les entourent suffit, et les hachages de flux comme le diff d'empreintes déclarent tous deux des révisions identiques tandis que le renderer produit des couleurs légèrement différentes sur chaque page

À quoi un nombre réel PDF a le droit de ressembler

ISO 32000-1 §7.3.3 définit un nombre réel comme une chaîne décimale : un signe facultatif, des chiffres, un point, d'autres chiffres. Pas de notation de base. Pas de forme exponentielle. L'annexe C, qui liste les limites d'implémentation, attend qu'environ cinq chiffres décimaux significatifs de la partie fractionnaire soient honorés. Une précision de sortie par défaut de quatre se place donc déjà un chiffre en dessous de ce que la spécification dit aux lecteurs de conserver, et le fichier n'a rien fait de mal en en utilisant cinq

Près de zéro, un formateur naïf fait pire que perdre un chiffre. La façon courante d'imprimer un Double à N décimales est de multiplier par 10^N, d'arrondir à un entier et d'écrire les chiffres, et quand cet entier vaut zéro, la sortie est 0. Donnez-lui le premier opérande de 0.00001 0 0 1 0 0 cm à quatre décimales et le résultat est 0 0 0 1 0 0 cm, une matrice singulière qui fait disparaître tout ce qui est dessiné dessous. Une entrée /Matrix de -0.000012345 subit le même sort

Quand PDF Library for Delphi (PDFlibPas) doit formater lui-même un Double, il garde le nombre demandé de chiffres significatifs pour une valeur non nulle plus petite que le pas de précision au lieu d'écrire 0. À quatre décimales, 0.00001 s'écrit 0.00001 et 0.0000123 s'écrit 0.0000123 ; seules les magnitudes en dessous d'environ 5E-16 sortent encore en 0. Cela garde les nombres nouvellement écrits sains. Un nombre parsé qui n'a jamais eu besoin d'être reformaté est une autre affaire, et la bibliothèque le gère en ne le reformatant pas du tout

Comment PDFlibPas garde le littéral qu'il a parsé

TPDFStructure.Decode garde le texte source de chaque réel standard qu'il tokenise, c'est-à-dire un token avec un point décimal et sans marqueur d'exposant, juste à côté du Double converti. TPDFNumeric.Output réécrit ce texte chaque fois qu'il est présent et ne formate le Double au PDFPrecNum de la structure que lorsqu'il ne l'est pas. TPDFNumeric.SetTo jette le texte, parce qu'un nombre modifié est un nouveau nombre et doit suivre la précision de sortie courante

Comment PDFlibPas préserve le texte décimal parsé en Delphi : le tokenizer de TPDFStructure.Decode garde le littéral source à côté de la valeur convertie pour tout token avec un point décimal et sans exposant, Output réécrit ce texte tel quel au lieu de reformater le Double à PDFPrecNum, SetTo l'efface 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 parsé 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 une entrée intacte

Lier la remise à zéro à SetTo était délibéré. Le raccourci tentant est d'émettre le texte d'origine sauf si le drapeau Changed de l'objet est levé, puisque ce drapeau existe déjà sur chaque objet. Mais le pipeline de sauvegarde efface Changed une fois l'objet écrit, donc une valeur modifiée, sauvegardée puis modifiée à nouveau dans la même session recommencerait à émettre son littéral périmé à la seconde sauvegarde. Effacer le texte dans l'affectation elle-même signifie que le littéral et la valeur ne peuvent jamais diverger

Quelques frontières sont intentionnelles. Les entiers ne sont pas stockés en texte parce que le formatage des entiers est déjà sans perte. Les formes exponentielles comme 6.02E23 sont acceptées en entrée par égard pour les producteurs qui les écrivent quand même, mais elles sont formatées comme tout nombre généré par la bibliothèque en sortie, puisque les recopier perpétuerait une syntaxe que §7.3.3 exclut. Le tokenizer applique aussi sa petite réparation habituelle avant de stocker le texte : un littéral à point initial comme .5 est gardé en 0.5, un littéral à point final comme 5. en 5.0. Chaque lecteur traite chaque paire comme le même nombre, et l'orthographe réparée est acceptée bien plus largement

L'extrait ci-dessous fait passer les valeurs CalRGB directement par la couche objet, pour que chaque règle se voie sans ouvrir de fichier PDF. Les lignes Assert sont des contrôles que nous suggérons d'ajouter à vos propres tests ; ce ne sont pas des appels à la bibliothèque

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]'));

    // Votre propre contrôle : une entrée non modifiée survit telle quelle, y compris
    // la valeur qu'un formateur naïf à quatre décimales réduirait à 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');

    // Les nouvelles valeurs minuscules gardent leurs chiffres significatifs au lieu de devenir 0
    Assert(Structure.NewNumeric(0.0000123).Output = '0.0000123');

    // Baisser 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;

Un piège quand on écrit des assertions de ce genre : le Round de Delphi utilise l'arrondi bancaire, et 0.000012345 * 1e8 vaut 1234.4999… en binaire plutôt que 1234.5. Nous sommes tombés en plein sur cette valeur en vérifiant le chemin de formatage près de zéro. Choisissez des entrées de test qui ne sont pas des milieux binaires chaque fois qu'une assertion dépend de l'arrondi du dernier chiffre

Que contrôle SetPrecision ?

TPDFlib.SetPrecision fixe les décimales des nombres que la bibliothèque écrit : les graphiques dessinés via le painter, les nombres créés depuis un Double par exemple via NewNumeric, et les valeurs parsées qui ont depuis été changées avec SetTo. Il accepte 2 à 8 et renvoie 0 pour tout ce qui sort de cette plage. Un décimal parsé que personne n'a modifié garde son texte d'entrée quel que soit le réglage, et changer le réglage après LoadFromFile ne remonte pas jusqu'à lui. Le source passé à SetObjectFromString passe par le même tokenizer et est conservé de la même façon

Notre recommandation est de laisser SetPrecision tranquille sauf si la géométrie que vous générez a besoin de pas plus fins. Le relever pour protéger les fichiers d'entrée n'apporte rien, parce qu'une entrée intacte garde déjà ses propres chiffres, et cela allonge chaque nombre que vous dessinez. Cinq décimales sont un choix raisonnable pour du dessin sous le point ; huit gonfle surtout les flux de contenu

uses
  PDFlibrary;

var
  PDF: TPDFlib;
begin
  PDF := TPDFlib.Create;
  try
    if PDF.LoadFromFile('calibrated.pdf', '') <> 1 then
      Exit;

    // Gouverne les nombres écrits à partir d'ici ; les tableaux /Gamma,
    // /Matrix et /WhitePoint parsés gardent leur texte d'origine
    PDF.SetPrecision(5);

    PDF.SelectPage(1);
    PDF.SetLineWidth(0.123456);       // les nombres dessinés utilisent cinq décimales
    PDF.DrawLine(72, 72, 300, 72);

    PDF.SaveToFile('calibrated-out.pdf');
  finally
    PDF.Free;
  end;
end;

Flux de contenu : les modifications gardent les opérandes, la normalisation est opt-in

Le modèle de contenu éditable garde le texte des opérandes par défaut et ne canonise les nombres que lorsque la sortie canonique est le but de l'appel. TPDFContentProgram.CanonicalNumbers vaut False par défaut, donc une couche renvoyée par ParsePageContentLayer, le même modèle sur lequel est bâti le traqueur d'état graphique, réémet chaque opérande numérique intact exactement tel qu'écrit. Les modifications de page qui réécrivent une couche entière, dont ScalePage, TransformPageElement, DeletePageElement, RedactRegion, ConvertTextToPaths et votre propre programme réécrit avec WritePageContentLayer, ne changent que les opérandes que la modification a touchés

L'autre défaut a l'air plus propre : canoniser à chaque parse, pour que le chemin d'édition et NormalizeContentStreams produisent toujours la même orthographe. Nous pensons que c'est le mauvais défaut pour l'édition. Un appel ScalePage sur une couche contenant 0.123456 w réécrirait 0.1235 w, et chaque autre opérande de la couche serait réécrit aussi, alors que l'appelant n'a demandé qu'une mise à l'échelle. La forme canonique appartient aux opérations dont le but est une sortie comparable

Ces opérations l'obtiennent toujours. NormalizeContentStreams et l'optimiseur de contenu à la sauvegarde parsent avec CanonicalNumbers à True, parce que leur travail est une sortie stable à partir d'une entrée arbitraire ; si 0.50000 0 0 RG et 0.5 0 0 RG s'émettaient différemment, chaque comparaison en aval dériverait avec les habitudes de formatage du producteur. La canonisation descend récursivement dans les tableaux et dictionnaires, donc les motifs de tirets, les tableaux TJ et les dictionnaires de propriétés de contenu marqué sont couverts, tandis que les données brutes d'images inline sont laissées tranquilles. Les opérandes fournis via SetOperand sont canonisés quoi que dise le drapeau. Un programme de contenu porte son propre magasin de nombres fixé à quatre décimales, indépendant de SetPrecision, donc un opérande modifié ressort avec quatre. Seul Parse lit le drapeau, ce qui signifie qu'il doit être posé avant le parsing ; pour un programme que ParsePageContentLayer a déjà parsé, posez le drapeau et appelez Parse(Emit)

Quand le modèle de contenu PDFlibPas normalise les nombres : avec CanonicalNumbers à True chaque opérande numérique est canonisé au moment où le parseur l'empile, les opérandes passés à SetOperand sont toujours canonisés, la passe descend récursivement dans les tableaux et dictionnaires pour couvrir motifs de tirets, tableaux TJ et dictionnaires de propriétés de contenu marqué, et SetTo AsDouble efface le texte d'origine pour que 0.50000 et 0.5 s'émettent à l'identique
Les opérandes numériques canoniques sont ce que demandent NormalizeContentStreams et l'optimiseur : les données brutes d'images inline sont laissées tranquilles, et les nombres de dictionnaire intacts hors des flux de contenu gardent la garantie du verbatim, donc une simple paire LoadFromFile et SaveToFile les préserve toujours
uses
  PDFlibrary, PDFlibContentModel, PDFlibContentOps;

var
  PDF: TPDFlib;
  Prog: TPDFContentProgram;
  I: Integer;
begin
  PDF := TPDFlib.Create;
  try
    if PDF.LoadFromFile('calibrated.pdf', '') <> 1 then
      Exit;
    PDF.SelectPage(1);
    Prog := PDF.ParsePageContentLayer(1);
    if Prog = nil then
      Exit;
    try
      // Change une épaisseur de ligne ; chaque autre opérande garde son texte source
      I := Prog.FindNextOp(0, coLineWidth);
      if I >= 0 then
        Prog.SetOperand(I, 0, '0.123456');   // émis comme 0.1235
      PDF.WritePageContentLayer(1, Prog);

      // Copie canonique pour comparaison seulement, pas réécrite :
      // Parse est le seul lecteur du drapeau, donc re-parsez après l'avoir posé
      Prog.CanonicalNumbers := True;
      Prog.Parse(Prog.Emit);
      // Prog.Emit écrit maintenant 0.50000 et 0.5 de la même façon
    finally
      Prog.Free;
    end;
    PDF.SaveToFile('calibrated-out.pdf');
  finally
    PDF.Free;
  end;
end;

Mises bout à bout, la règle pour les appelants est courte. Un simple LoadFromFile suivi de SaveToFile laisse les flux de contenu intacts et les nombres de dictionnaire intacts tels qu'ils étaient. Une page modifiée via le modèle de contenu garde le texte de chaque opérande que la modification n'a pas changé. NormalizeContentStreams, l'optimiseur qui tourne quand le contenu est compressé à la sauvegarde, et tout programme parsé avec CanonicalNumbers à True produisent des nombres canoniques par conception, et le reste du document est toujours préservé

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

Chaque TPDFNumeric porte une référence AnsiString de plus, et chaque décimal parsé garde son texte source vivant aussi longtemps que l'objet vit. Sur un document à plusieurs millions de nombres réels, c'est de la mémoire mesurable, et cela a sa place dans tout benchmark de gros documents que vous lancez. La garantie se limite aussi au document propre d'un nombre. Copier des objets entre documents, ou reconstruire des valeurs via l'API objet, produit de nouveaux nombres, et les nouveaux nombres suivent la précision de sortie

Nombres préservés et sortie identique à l'octet sont deux propriétés distinctes. Un chargement-sauvegarde d'un document intact garde les nombres de calibration que le renderer consomme, mais l'identité d'octets dépend aussi de la numérotation des objets, de la compression des flux et de l'identifiant du trailer couvert dans l'article sur l'ID PDF déterministe. Le diff d'empreintes continue de hacher le corps normalisé, ce qui veut dire que les différences d'arrondi dans les fichiers écrits par d'autres outils échappent à ce qu'il compare ; une comparaison de rendu reste le contrôle pour celles-là

La gestion numérique et la sémantique exacte de SetPrecision sont couvertes dans la référence de la losLab PDF Developer Library, avec les appels du modèle de contenu utilisés ci-dessus