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
/CreationDateet/ModDate(ISO 32000-1 §14.3.3, Table 317) sous forme de 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. HotPDF horodate les deux depuisFCreationDate, que le constructeur initialise àNow, donc les deux sauvegardes diffèrent de la seconde où elles ont été écrites - L'identifiant. Le tableau
/IDdu 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 plusGetTickCountpour 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,/Oet/OEsont 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/IDdans 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
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
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.
_DateTimeToPdfDateajoute le décalage UTC local, doncD:20260101000000+08'00'sur un agent de build etD: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.
SaveIncrementalUpdatecalcule son identifiant de modification à partir du chemin cible, deGetTickCountet 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.
SaveLoadedDocumentcopie 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
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é