Deux PDF écrits par le même code de facturation à une seconde d'intervalle ne sont presque jamais le même fichier. Une comparaison binaire bute d'abord dans le dictionnaire Info, où /CreationDate a bougé d'une seconde, puis dans le paquet XMP, puis au /ID du trailer. Activez AES-256 et la comparaison s'effondre sur presque chaque flux, parce que la clé de chiffrement du fichier elle-même vient d'être générée. Masquer les dates avant la compare répare les deux premières différences et aucune des autres. Un test à fichier témoin ne devient digne de foi que quand chaque valeur que l'écrivain prend d'ailleurs que du document est figée au point où elle est lue
Quatre valeurs qui ne viennent jamais du document
HotPDF Delphi Component, comme tout écrivain PDF conforme, remplit quatre sortes de valeurs depuis l'extérieur du contenu de page : l'horloge, l'identifiant de document, la source aléatoire cryptographique et l'ordre en mémoire des entrées de dictionnaire. ISO 32000-1 a une raison pour chacune
- L'horloge. Le dictionnaire Info porte
/CreationDateet/ModDate(ISO 32000-1, §14.3.3, table 317) comme chaînesD:YYYYMMDDHHmmSSavec un suffixe de fuseau horaire (§7.9.4), et le paquet XMP répète le même instant enxmp:CreateDateetxmp:ModifyDate. Tout est rendu depuis un horodatage de création unique que le composant prend àNowau moment de sa construction - L'identifiant. Le tableau
/IDdu trailer (§14.4) porte un identifiant permanent et un identifiant de modification. Par défaut, le premier est un MD5 du nom de fichier plus l'heure courante à la milliseconde, et le second hache à nouveau cette valeur avec le tick count système - Les octets aléatoires. Un document AES-256 demande une clé de chiffrement de fichier de 32 octets, un sel de validation de 8 octets et un sel de clé de 8 octets pour
/U, une autre paire pour/O, quatre octets de remplissage dans/Perms, et un vecteur d'initialisation de 16 octets devant chaque chaîne et flux chiffrés. ISO 32000-2, §7.6.4.4.7, veut les sels aléatoires. RC4 et AES-128 dérivent leur clé des mots de passe et du premier élément/ID(ISO 32000-1, §7.6.3.3), si bien qu'un identifiant neuf à lui seul suffit à re-keyer tout le fichier - L'ordre. Un dictionnaire PDF est un mapping non ordonné (§7.3.7). Un écrivain qui parcourt sa liste en mémoire émet les clés dans l'ordre où le code les a ajoutées, si bien que deux chemins de code qui construisent le même dictionnaire de ressources dans une séquence différente produisent un fichier légal mais textuellement différent
Tout le reste découle déjà de l'entrée : les flux de contenu, les données d'images compressées et les offsets de table de références se répètent pour les mêmes appels de dessin. Même le champ qui a l'air aléatoire ne l'est pas : le préfixe de six lettres d'une sous-partie de police incorporée (ISO 32000-1, §9.6.4, la partie ABCDEF+ de ABCDEF+Arial) est calculé depuis l'ensemble des glyphes que la sous-partie garde, si bien que le même texte donne le même préfixe à chaque exécution. Le bruit se trouve dans les métadonnées et la couche de sécurité, voilà pourquoi une seule propriété peut le retirer
Ce que ReproducibleOutput fige
Poser ReproducibleOutput := True avant BeginDoc remplace chacune des quatre sources par une valeur fixe, dans les mêmes chemins de code qui iraient sinon chercher l'horloge ou le générateur aléatoire. Il n'y a pas de passe de nettoyage ensuite. La propriété vaut False par défaut, et rien dans la bibliothèque ne l'allume pour vous
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // doit être posé 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;
Sous le drapeau, BeginDoc pose l'horodatage de création au 1er janvier 2026, 00:00 heure locale, et comme les dates Info et les dates XMP sont rendues depuis cette unique valeur, une seule affectation les couvre toutes. La même branche sème l'identifiant de document interne depuis une chaîne constante au lieu du nom de fichier et de l'horloge. À l'écriture du fichier, le /ID du trailer est calculé comme un MD5 sur un export canonique de tout le graphe d'objets dans lequel les chiffres de chaque chaîne de date D: sont d'abord remis à zéro, si bien qu'un horodatage ne peut pas se faufiler dans le hachage. Les deux éléments de /ID reçoivent cette valeur. Un document chargé que vous chiffrez sans jamais appeler BeginDoc (posez ActivateProtection après LoadFromFile) reçoit le même identifiant dérivé du contenu
Les octets aléatoires sont remplacés par des constantes. La clé de fichier AES-256, les quatre sels et le remplissage /Perms sont remplis avec $5A, et chaque vecteur d'initialisation CBC, pour les chaînes et flux AES-128 comme AES-256, devient le motif 14 * (1 + I) pour l'octet I. Ce motif est non nul exprès. Un IV tout à zéro est le choix faible du manuel pour CBC, et une suite de régression n'a pas à le normaliser. Avec clé, sels et vecteurs fixés, /U, /UE, /O, /OE, /Perms et chaque flux chiffré sortent identiques à la seconde exécution
Enfin, SaveToStream allume DeterministicDictionaryOrder dès que le drapeau reproductible est posé, et le fait avant de choisir entre les dispositions linéarisée, object-stream et classique, si bien que la sortie avec LinearizeOutput ou UseObjectStreams est triée aussi. Les clés sont ordonnées par leurs octets bruts, une clé qui est préfixe d'une autre se trie en premier, et la position d'origine départage les égalités. C'est l'ordre qu'utilise l'écrivain de diagnostic, décrit dans l'article sur l'édition à la main d'un PDF et sa réparation ensuite ; le drapeau reproductible emprunte l'ordre et rien de la disposition en texte brut de cet écrivain. Allumer DeterministicDictionaryOrder tout seul est une demi-mesure. Les clés s'alignent, mais les dates Info et les deux chaînes /ID du trailer portent encore l'horloge, si bien que deux sauvegardes continuent de différer aux mêmes quatre endroits
Un test qui ne peut pas passer par chance
La date est fixée dans BeginDoc parce que le constructeur tourne avant que l'appelant ait eu la moindre chance de poser la propriété. La séquence normale est Create, puis ReproducibleOutput := True, puis BeginDoc, et seul le dernier voit le drapeau avec sa valeur finale. Cet ordre a une conséquence directe sur la conception des tests. Deux sauvegardes qui atterrissent dans la même seconde d'horloge écrivent la même chaîne D: par accident, si bien qu'un test rapide passe que la date soit figée ou non, puis échoue des mois plus tard sur un agent de build plus lent
Le remède coûte peu : attendez 1100 ms entre les deux sauvegardes pour que l'horodatage traverse à coup sûr une frontière de seconde, exécutez le cas pour la sortie simple, AES-128 et AES-256 avec de vrais mots de passe sur les variantes chiffrées, et rapportez l'offset du premier octet différent plutôt qu'un vrai ou faux nu. Un offset pointe vers un objet. Un booléen pointe vers tout le fichier
uses
System.SysUtils, System.IOUtils, HPDFDoc;
function SaveOnce(const Target: string; Encrypt: Boolean;
KeyType: THPDFKeyType): TBytes;
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := Target;
Pdf.ReproducibleOutput := True;
Pdf.VerifySavedOutput := True; // rechargement strict juste après EndDoc
if Encrypt then
begin
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := KeyType; // aes128 ou aes256
Pdf.ActivateProtection := True;
end;
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
Pdf.EndDoc;
finally
Pdf.Free;
end;
Result := TFile.ReadAllBytes(Target);
end;
// Votre propre aide : -1 si égaux, sinon le premier offset qui diffère
function FirstDifference(const A, B: TBytes): Integer;
var
I, N: Integer;
begin
N := Length(A);
if Length(B) < N then
N := Length(B);
for I := 0 to N - 1 do
if A[I] <> B[I] then
Exit(I);
if Length(A) <> Length(B) then
Result := N
else
Result := -1;
end;
procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
A, B: TBytes;
Offset: Integer;
begin
A := SaveOnce('run-a.pdf', Encrypt, KeyType);
Sleep(1100); // force une seconde d'horodatage différente
B := SaveOnce('run-b.pdf', Encrypt, KeyType);
Offset := FirstDifference(A, B);
if Offset >= 0 then
raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;
Appelez CheckReproducible(False, aes128), CheckReproducible(True, aes128) et CheckReproducible(True, aes256) ; le type de clé est ignoré à l'exécution simple. VerifySavedOutput mérite sa place dans ce test. Avec lui actif, EndDoc recharge le fichier qu'il vient d'écrire dans une instance THotPDF fraîche avec la récupération de table de références désactivée, en utilisant le mot de passe utilisateur quand le document est protégé, et lève si ce chargement échoue. Un changement qui rendrait la sortie stable et illisible d'un coup passerait comme une lettre à la poste sur un diff vert ; le rechargement l'arrête
Un PDF chiffré reproductible reste-t-il sûr ?
Non. Un document chiffré sous ReproducibleOutput n'a aucune protection réelle, et le drapeau doit rester éteint pour tout ce qui quitte le répertoire de test. La clé de fichier AES-256 fait trente-deux octets de $5A. Le mot de passe conditionne toujours les enveloppes /UE et /OE, mais ce qu'elles enveloppent est une constante publiée, si bien que quiconque la connaît peut déchiffrer chaque flux de contenu sans mot de passe. Les sels fixés retirent aussi l'unicité par fichier sur laquelle ISO 32000-2, §7.6.4.4.7, compte pour empêcher le même mot de passe de produire la même chaîne /U dans deux fichiers. L'article sur la configuration AES-256 décrit ce que les propriétés de chiffrement livrent avec la source aléatoire intacte ; sous le drapeau reproductible, rien de cela ne s'applique. Gardez la propriété derrière une valeur de configuration réservée aux tests, pas un réglage qu'un chemin de code de production peut atteindre
AES-GCM est traité autrement. Avec CryptKeyLength := aesgcm (PDF 2.0 seulement), le composant tire le préfixe de nonce de 8 octets de la source aléatoire système, que ReproducibleOutput en dise ou non, puis compte vers le haut pour chaque chaîne ou flux chiffré. C'est le bon comportement. GCM se casse brutalement quand un nonce se répète sous la même clé : deux textes chiffrés fuient le XOR de leurs textes en clair, et un attaquant peut en récupérer assez pour forger des étiquettes d'authentification. Une clé figée plus un nonce figé rendraient cette répétition certaine dans chaque fichier que la suite produit. Les fichiers témoins chiffrés utilisent donc aes128 ou aes256, qui tournent en mode CBC
L'identifiant est un compromis plus doux. ISO 32000-1, §14.4, veut que le second élément /ID change à chaque modification, et une sauvegarde reproductible écrit la même valeur dans les deux slots. Comme cette valeur hache le graphe d'objets, des contenus différents reçoivent quand même des identifiants différents. Un lecteur qui indexe un cache sur /ID, un magasin d'annotations ou un fichier annexe de données de formulaire par exemple, traitera quand même deux sauvegardes reproductibles du même contenu comme un seul fichier. Dans une suite de tests, c'est exactement ce que vous voulez
Des entrées qui restent hors de l'écrivain
ReproducibleOutput couvre l'entropie que l'écrivain introduit de son propre chef. Quelques entrées de plus viennent de la machine de build ou d'opérations qui enregistrent par définition un nouveau moment
- Le suffixe de fuseau horaire. Chaque chaîne de date PDF finit par l'offset UTC local, si bien que la même date fixe se lit
D:20260101000000+08'00'sur un agent de build etD:20260101000000-05'00'sur un autre. Les fichiers témoins tiennent entre exécutions sur une machine, ou entre machines qui partagent un fuseau. Si les fichiers voyagent entre bureaux, figez le fuseau des agents - Mises à jour incrémentales.
SaveIncrementalUpdategarde l'identifiant permanent de la source et construit un nouvel identifiant de modification depuis le précédent, le chemin cible, le tick count et l'heure courante. Une section appendue est une nouvelle modification par définition. Comparez des réécritures complètes, pas des deltas appendus - Le raccourci passthrough.
SaveLoadedDocumentcopie normalement une source non modifiée et non chiffrée octet pour octet au lieu de la sérialiser encore. Sous le drapeau, le fichier est toujours réécrit pour que les règles d'ordre et d'identifiant s'appliquent, ce qui rend la sauvegarde plus lente et jamais copie de l'entrée. Comparez-la à une sauvegarde reproductible précédente, jamais à l'original - Horodatages que vous ajoutez vous-même.
AddDocumentAttachmentdâte les/Params/CreationDateet/ModDatedu fichier incorporé (ISO 32000-1, §7.11.4) avec le moment de l'appel, et une signature enregistre son heure de signature dans/M. Ces dates décrivent des événements demandés par l'appelant, si bien que le drapeau les laisse tranquilles. Gardez pièces jointes et signatures hors des cas au niveau octet, ou masquez ces chaînes dans la compare - Polices installées. Une sous-partie TrueType est coupée dans le fichier de police installé sur la machine qui exécute le test. Deux agents avec des versions d'Arial différentes incorporent des programmes de glyphes différents pour le même texte, et le préfixe de sous-partie peut changer avec eux. Livrez les fichiers de polices avec l'image de build
Les documents chargés suivent les mêmes règles. Posez la propriété avant le chargement, faites vos éditions, et enregistrez par SaveLoadedDocument ; la sortie est une réécriture normalisée que vous pouvez committer comme référence pour la prochaine exécution
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.ReproducibleOutput := True; // à poser avant le chargement
if Pdf.LoadFromFile('incoming.pdf') <= 0 then
raise Exception.Create('incoming.pdf did not load');
// ... éditions sous test ...
Pdf.SaveLoadedDocument('normalized.pdf'); // réécriture complète, clés triées
finally
Pdf.Free;
end;
end;
Les sauvegardes identiques octet par octet coincent aussi un contrôle de conformité qui vacille. Une fois l'écrivain stable, un contrôle qui passe à une exécution et échoue à la suivante réagit au graphe d'objets que le test construit, par exemple un dictionnaire direct remis à deux parents, alors inspectez l'entrée du test avant le validateur. Un diff structurel depuis le catalogue montre quel objet a bougé entre les deux exécutions
Les propriétés ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput et de chiffrement utilisées ci-dessus font partie du HotPDF Delphi Component, qui se livre pour Delphi comme pour C++Builder