Article technique

Sortie PDF reproductible en Delphi : sauvegardes identiques

HotPDF Delphi Component produit une sortie PDF identique octet pour octet d'une sauvegarde à l'autre quand la propriété ReproducibleOutput vaut True : il fige les /CreationDate et /ModDate du dictionnaire Info sur une date fixe, remplace l'identifiant de document issu de l'horloge par un hachage amorcé ou dérivé du contenu, substitue des constantes à chaque octet aléatoire que les chemins de chiffrement AES tireraient sinon, et trie chaque dictionnaire qu'il sérialise. Le drapeau existe pour les suites de régression et la comparaison d'artefacts de build, pas pour les documents de production, et les raisons de cette frontière sont la partie intéressante. Le scénario qui motive la fonction est un test de fichier de référence. Vous générez une facture, vous commitez le PDF, et vous affirmez que la compilation de demain produira les mêmes octets. Elle ne le fait jamais. Le fichier s'ouvre parfaitement dans toutes les visionneuses, le texte est identique, l'arbre de pages est identique, et le diff s'allume quand même à quatre ou cinq endroits. Quiconque a tenté de soumettre un générateur PDF à un test de régression au niveau de l'octet a heurté ce mur, et le correctif n'est pas « supprimer les horodatages » mais une comptabilité précise de chaque endroit où le writer consulte autre chose que le document lui-même

Pourquoi deux sauvegardes du même PDF diffèrent-elles ?

Deux sauvegardes du même document diffèrent parce qu'un writer PDF, HotPDF compris, consulte quatre sources d'entropie qui n'ont rien à voir avec le contenu des pages : l'horloge système, l'identifiant de document, le générateur de nombres aléatoires cryptographiques, et l'ordre en mémoire des entrées de dictionnaire. Chacune est légitime en soi. ISO 32000-1 les veut là. Elles rendent simplement le fichier fonction de quand et où il a été écrit plutôt que de ce qu'il contient

  • L'horloge. Le dictionnaire Info porte /CreationDate et /ModDate (ISO 32000-1 §14.3.3, Table 317) sous forme de chaînes D:YYYYMMDDHHmmSS avec un suffixe de fuseau horaire (§7.9.4), et le paquet XMP répète le même instant en xmp:CreateDate et xmp:ModifyDate. HotPDF horodate les deux depuis FCreationDate, que le constructeur initialise à Now, donc les deux sauvegardes diffèrent de la seconde où elles ont été écrites
  • L'identifiant. Le tableau /ID du trailer (ISO 32000-1 §14.4) détient un identifiant permanent et un identifiant de modification. La recette par défaut de HotPDF hache le nom de fichier avec l'heure courante à la milliseconde près pour le premier élément, et hache cela plus GetTickCount pour le second. Deux identifiants, deux valeurs neuves à chaque exécution
  • Les octets aléatoires. La sécurité standard dépend de l'identifiant et d'un vrai aléa. Pour AES-256, la clé de chiffrement de fichier, les sels de validation et de clé, et chaque vecteur d'initialisation CBC sont tirés de la source aléatoire du système (ISO 32000-2 §7.6.4.4.7 exige des sels aléatoires). Comme /U, /UE, /O et /OE sont tous calculés à partir de ces octets, un document chiffré change en totalité même quand le clair ne change pas. Les algorithmes plus anciens replient le premier élément /ID dans la clé (ISO 32000-1 §7.6.3.3, §7.6.3.4), donc un identifiant neuf suffit à lui seul à rechiffrer le fichier
  • L'ordre. Un dictionnaire PDF est une association non ordonnée, et un writer qui parcourt sa liste en mémoire émet les clés dans l'ordre d'insertion. Tout chemin de code qui construit un dictionnaire de ressources dans une séquence différente, ou un document chargé qui a été analysé depuis une disposition différente, produit un fichier légal mais textuellement différent
Les quatre sources d'entropie qui font différer deux sauvegardes HotPDF d'un même document : FCreationDate horodaté depuis Now alimente les dates D: et le paquet XMP, le /ID du trailer hache nom de fichier, horloge et GetTickCount, AES tire le matériel de clé de la source aléatoire du système, et les dictionnaires se sérialisent dans l'ordre d'insertion en mémoire
Chaque source est légitime en soi et ISO 32000-1 les veut là, mais ensemble elles transforment le fichier en fonction de quand et où il a été écrit plutôt que de ce qu'il contient

Que fige exactement ReproducibleOutput ?

Définir ReproducibleOutput := True avant BeginDoc ou avant SaveLoadedDocument remplace chacune des quatre sources par une valeur fixe, et il le fait dans les mêmes chemins de code qui iraient sinon chercher l'horloge ou le générateur aléatoire, donc aucune passe de nettoyage séparée n'est nécessaire. Remarquez ce qui manque dans la liste ci-dessus : le contenu. Les polices, les flux de page, les données d'image et la table de références croisées sont déjà déterministes pour la même entrée ; le bruit vit entièrement dans les métadonnées et la couche de sécurité, ce qui explique qu'une propriété ciblée suffise à le retirer. La propriété vaut False par défaut et rien dans la bibliothèque ne l'active pour vous

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // avant BeginDoc
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

À l'intérieur de BeginDoc, la branche reproductible affecte FCreationDate := EncodeDate(2026, 1, 1) et amorce l'identifiant de document avec MD5CalcString('HotPDF-reproducible-seed') au lieu du condensat nom de fichier plus horloge. Cette seule affectation couvre les deux dates Info et les deux dates XMP, parce que les quatre sont rendues depuis le même champ. Quand le fichier est finalement écrit, BuildDocumentIdentifiers demande à ComputeCanonicalDocumentIdentifier l'identifiant de trailer : il exporte tout le graphe d'objets dans l'ordre canonique, met à zéro les chiffres de toute chaîne de date D: qu'il trouve pour que les horodatages ne puissent pas revenir par le hachage, et prend le MD5 du résultat. Les deux éléments de /ID reçoivent cette valeur. Le même identifiant dérivé du contenu est utilisé quand un document chargé est chiffré sans jamais passer par BeginDoc, ce qui est le cas d'ActivateProtection sur un fichier que vous avez ouvert avec LoadFromFile

Les octets aléatoires sont la substitution la moins évidente. La routine de clé AES-256 enveloppe sa source aléatoire dans un helper local qui, sous le drapeau, appelle FillChar(P^, Count, $5A) pour la clé de chiffrement de fichier de 32 octets et pour chaque sel de 8 octets, et les chiffreurs de chaînes et de flux AES-128 et AES-256 passent d'AESGenerateRandomIV à AESGenerateStaticIV, qui remplit le vecteur d'initialisation avec 14 * (1 + I) pour la case I. La clé, les sels et les vecteurs étant tous figés, /U, /UE, /O, /OE et chaque flux chiffré ressortent identiques à la seconde exécution. Enfin, SaveToStream active DeterministicDictionaryOrder dès que le drapeau reproductible est positionné, et le sérialiseur trie alors chaque dictionnaire par insertion sur les octets bruts des noms de clés, préfixe le plus court d'abord, avec l'index d'origine comme départage. C'est le même ordre que celui du writer de diagnostic, décrit dans l'article sur l'édition manuelle d'un PDF et sa réparation ensuite ; le drapeau reproductible n'emprunte que l'ordonnancement, pas le reste de la disposition en texte clair de ce writer

Ce que ReproducibleOutput fige dans HotPDF : la date de création devient EncodeDate 2026, 1, 1, l'identifiant de trailer vient de ComputeCanonicalDocumentIdentifier sur le graphe canonique avec les chiffres D: mis à zéro, les clés et sels AES se remplissent d'octets $5A et AESGenerateStaticIV remplit chaque case, et DeterministicDictionaryOrder trie chaque dictionnaire
Les substitutions s'exécutent dans les mêmes chemins de code qui iraient sinon chercher l'horloge ou le générateur aléatoire, donc aucune passe de nettoyage séparée n'est nécessaire et les deux éléments /ID reçoivent la même valeur dérivée du contenu

Pourquoi la date fixe laissait-elle encore fuir l'horloge ?

Le correctif de la v2.752.2 existe parce que la date de création fixe était décidée à l'origine dans le constructeur, et que le constructeur ne peut pas connaître une propriété que l'appelant n'a pas encore définie. La séquence d'appel normale est Create, puis ReproducibleOutput := True, puis BeginDoc. Au moment de la construction, FReproducibleOutput vaut encore False, donc FCreationDate recevait Now et le gardait. L'identifiant et les octets aléatoires étaient correctement figés, donc les deux fichiers s'accordaient presque partout et divergeaient exactement sur deux chaînes de date et deux champs XMP. Déplacer l'affectation dans la branche reproductible de BeginDoc, à côté de l'identifiant amorcé, a remis la décision au point où la propriété a sa valeur définitive

Le test de régression qui a raté cela vaut plus que le correctif. Deux sauvegardes qui s'exécutent dans la même seconde d'horloge écrivent la même chaîne D: par accident, et la comparaison d'octets passe pour un bug qui échoue sur n'importe quelle machine plus lente. Le test corrigé dort 1100 ms entre les deux sauvegardes pour que l'horodatage PDF franchisse à coup sûr une frontière de seconde, exécute le cas pour une sortie en clair, en AES-128 et en AES-256 avec de vrais mots de passe sur les deux variantes chiffrées, et compare les deux tampons avec CompareMem, en rapportant le premier décalage divergent en cas d'échec pour que le diff pointe un objet précis au lieu d'un fichier entier. Une comparaison d'octets prouve le déterminisme et rien d'autre, donc gardez une assertion séparée qui recharge la sortie chiffrée avec le mot de passe utilisateur et lit un nombre de pages ; une modification qui rend le fichier stable et illisible en même temps ne doit pas passer sur la foi d'un diff vert

function SaveOnce(const Target: string): TBytes;
var
  Pdf: THotPDF;
  Stream: TFileStream;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.CryptKeyLength := aes256;
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Stream := TFileStream.Create(Target, fmOpenRead or fmShareDenyWrite);
  try
    SetLength(Result, Stream.Size);
    if Stream.Size > 0 then
      Stream.ReadBuffer(Result[0], Stream.Size);
  finally
    Stream.Free;
  end;
end;

// dans le corps du test
A := SaveOnce(PathA);
TThread.Sleep(1100);          // forcer une seconde différente dans l'horodatage PDF
B := SaveOnce(PathB);
Assert.AreEqual<Integer>(Length(A), Length(B));
Assert.IsTrue(CompareMem(@A[0], @B[0], Length(A)),
  'two saves under ReproducibleOutput must be byte-identical');

Un PDF chiffré reproductible reste-t-il sûr ?

Non. Un document chiffré sous ReproducibleOutput n'est protégé en aucun sens utile, et le drapeau doit être désactivé pour tout ce qui sort du répertoire de test. La clé de chiffrement de fichier AES-256 est de trente-deux octets de $5A, les sels de huit octets de $5A, et les vecteurs d'initialisation suivent un schéma arithmétique publié. Le mot de passe contrôle encore les enveloppes /UE et /OE, mais la clé enveloppée est une constante, donc quiconque connaît la constante peut déchiffrer chaque flux de contenu sans mot de passe du tout. Des sels figés suppriment aussi l'unicité par document sur laquelle ISO 32000-2 §7.6.4.4.7 compte pour éviter que des mots de passe identiques donnent des chaînes /U identiques d'un fichier à l'autre. Lisez l'article sur la mise en place d'AES-256 pour savoir ce que promettent les propriétés de chiffrement quand la source aléatoire est intacte ; sous le drapeau reproductible, ces promesses sont suspendues

Le compromis sur l'identifiant est plus subtil. ISO 32000-1 §14.4 veut que le second élément /ID change à chaque modification pour que les outils distinguent un fichier mis à jour de son ancêtre, et une sauvegarde reproductible écrit la même valeur dans les deux cases. Comme cette valeur est un hachage du graphe d'objets canonique, deux documents au contenu différent obtiennent quand même des identifiants différents, ce qui vaut mieux qu'une constante. Mais la graine que BeginDoc utilise pour la dérivation de clé est la même chaîne pour chaque document sur chaque machine, et un lecteur qui se base sur /ID pour distinguer les fichiers, un cache d'annotations ou un fichier annexe de données de formulaire par exemple, confondra tous les fichiers reproductibles qui se trouvent hacher pareil

Que ne couvre pas le drapeau ?

ReproducibleOutput retire l'entropie que le writer introduit lui-même ; il ne peut pas retirer celle qui entre par l'environnement ou par des chemins de code qu'il ne contrôle pas, et trois de ces chemins sont faciles à heurter

  • Le suffixe de fuseau horaire. _DateTimeToPdfDate ajoute le décalage UTC local, donc D:20260101000000+08'00' sur un agent de build et D:20260101000000-05'00' sur un autre sont des octets différents pour la même date fixe. La reproductibilité tient d'une exécution à l'autre sur une même machine, ou sur des machines qui partagent un fuseau horaire ; figez le fuseau de l'agent si vos fichiers de référence voyagent
  • Les mises à jour incrémentales. SaveIncrementalUpdate calcule son identifiant de modification à partir du chemin cible, de GetTickCount et de l'heure courante sans branche reproductible, parce qu'une section incrémentale est par définition une nouvelle modification. Comparez des réécritures complètes, pas des deltas ajoutés
  • Le raccourci de recopie. SaveLoadedDocument copie normalement un fichier source non modifié et non chiffré octet pour octet au lieu de le resérialiser. Le drapeau reproductible désactive ce raccourci et force une réécriture complète pour que les règles d'ordre et d'identifiant s'appliquent, ce qui signifie que la sauvegarde reproductible d'un fichier chargé est plus lente que celle par défaut et n'est jamais une copie de l'entrée. Comparez-la à une sauvegarde reproductible précédente, jamais à l'original
Où s'arrêtent les sauvegardes reproductibles HotPDF : _DateTimeToPdfDate ajoute toujours le décalage UTC local, donc des fichiers de référence diffèrent entre fuseaux horaires, SaveIncrementalUpdate n'a pas de branche reproductible parce qu'un delta est une nouvelle modification, et le raccourci de recopie est désactivé donc un fichier chargé est toujours entièrement réécrit
La reproductibilité tient d'une exécution à l'autre sur une même machine ou sur des machines qui partagent un fuseau, et une sauvegarde reproductible se compare à une sauvegarde reproductible précédente, jamais au fichier d'entrée

Une leçon de plus, de la même version, à propos de ce qu'un contrôle qui passe prouve et ne prouve pas. Une fixture de test PDF/X-6 appelait CharProcs.DeleteValue('A'), ce qui libérait un flux de glyphe détenu directement, puis réinsérait le même pointeur, et confiait par ailleurs un objet ExtGState direct à la fois à un dictionnaire de ressources et à un motif. Le validateur de conformité passait par intermittence sur cet usage après libération et cette double possession, parce qu'il lisait ce que la mémoire libérée se trouvait contenir. Quand un contrôle structurel clignote, regardez la possession de l'entrée de test avant de regarder le validateur. La sortie reproductible rend cette discipline moins coûteuse : une fois deux sauvegardes identiques octet pour octet, la seule source restante de clignotement est le graphe d'objets lui-même, et un diff structurel depuis le catalogue vers le bas le trouvera

Les propriétés ReproducibleOutput, DeterministicDictionaryOrder et les propriétés de chiffrement décrites ici sont livrées dans le HotPDF Delphi Component standard pour Delphi et C++Builder, et le même drapeau pilote le corpus de régression de la bibliothèque, donc le comportement que vous obtenez dans une suite de tests est celui avec lequel le composant est testé