Dos PDF escritos por el mismo código de factura con un segundo de diferencia casi nunca son el mismo archivo. Una comparación binaria tropieza primero dentro del diccionario Info, donde /CreationDate se movió un segundo, después en el packet XMP, y luego en el /ID del trailer. Active AES-256 y la comparación colapsa en casi cada stream, porque la file encryption key se acaba de generar. Enmascarar las fechas antes de comparar repara las primeras dos diferencias y ninguna de las demás. Una prueba de golden file solo se vuelve confiable cuando cada valor que el writer toma de algo que no es el documento queda fijo en el punto donde se lee
Cuatro valores que nunca vinieron del documento
HotPDF Delphi Component, como todo writer de PDF conforme, llena cuatro clases de valor desde fuera del contenido de página: el reloj, el identificador del documento, la fuente aleatoria criptográfica y el orden en memoria de las entradas de diccionario. ISO 32000-1 tiene una razón para cada uno
- El reloj. El diccionario Info lleva
/CreationDatey/ModDate(ISO 32000-1 §14.3.3, Tabla 317) como stringsD:YYYYMMDDHHmmSScon un sufijo de zona horaria (§7.9.4), y el packet XMP repite el mismo instante comoxmp:CreateDateyxmp:ModifyDate. Todos se renderizan desde un único timestamp de creación que el componente toma deNowcuando se construye - El identificador. El array
/IDdel trailer (§14.4) guarda un identificador permanente y un identificador de modificación. Por omisión, el primero es un MD5 del nombre de archivo más la hora actual hasta el milisegundo, y el segundo hashea ese valor otra vez junto con el tick count del sistema - Los bytes aleatorios. Un documento AES-256 necesita una file encryption key de 32 bytes, una sal de validación de 8 bytes y una sal de clave de 8 bytes para
/U, otro par para/O, cuatro bytes de relleno dentro de/Perms, y un vector de inicialización de 16 bytes delante de cada string y stream cifrado. ISO 32000-2 §7.6.4.4.7 quiere las sales aleatorias. RC4 y AES-128 derivan su clave de las contraseñas y del primer elemento de/ID(ISO 32000-1 §7.6.3.3), así que un identificador fresco por sí solo alcanza para recambiar la clave de todo el archivo - El orden. Un diccionario PDF es un mapeo sin orden (§7.3.7). Un writer que recorre su lista en memoria emite las claves en el orden en que el código las agregó, así que dos caminos de código que arman el mismo diccionario de recursos en una secuencia distinta producen un archivo legal pero textualmente diferente
Todo lo demás ya se sigue de la entrada: los content streams, los datos de imagen comprimidos y los offsets de la tabla de referencias cruzadas se repiten para las mismas llamadas de dibujo. Ni siquiera el único campo que parece aleatorio lo es: el prefijo de seis letras de una fuente incrustada como subset (ISO 32000-1 §9.6.4, la parte ABCDEF+ de ABCDEF+Arial) se calcula a partir del conjunto de glifos que el subset conserva, así que el mismo texto produce el mismo prefijo en cada corrida. El ruido vive en los metadatos y en la capa de seguridad, y por eso una sola propiedad puede sacarlo
Qué fija ReproducibleOutput
Poner ReproducibleOutput := True antes de BeginDoc reemplaza cada una de las cuatro fuentes por un valor fijo, en los mismos caminos de código que si no irían a buscar el reloj o el generador aleatorio. No hay ninguna pasada de limpieza después. La propiedad viene en False por omisión, y nada en la librería la activa por usted
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // hay que fijarlo antes de BeginDoc
Pdf.BeginDoc;
Pdf.CurrentPage.SetFont('Arial', [], 12);
Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
Pdf.EndDoc;
finally
Pdf.Free;
end;
end;
Con el flag activo, BeginDoc fija el timestamp de creación en 1 de enero de 2026, 00:00 hora local, y como las fechas del Info y las del XMP se renderizan desde ese único valor, una sola asignación las cubre todas. La misma rama siembra el identificador interno del documento a partir de una cadena constante en lugar del nombre de archivo y el reloj. Cuando el archivo se escribe, el /ID del trailer se calcula como un MD5 sobre una exportación canónica de todo el grafo de objetos en la que los dígitos de cada string de fecha D: se ponen en cero primero, así que un timestamp no puede colarse de vuelta por el hash. Los dos elementos de /ID reciben ese valor. Un documento cargado que usted cifra sin llamar nunca a BeginDoc (ponga ActivateProtection después de LoadFromFile) recibe el mismo identificador derivado del contenido
Los bytes aleatorios se reemplazan con constantes. La file encryption key AES-256, las cuatro sales y el relleno de /Perms se llenan con $5A, y cada vector de inicialización CBC, tanto para strings y streams AES-128 como AES-256, pasa al patrón 14 * (1 + I) para el byte I. Ese patrón es distinto de cero a propósito. Un IV en cero es la elección débil de libro para CBC, y una suite de regresión no tiene por qué normalizarlo. Con la clave, las sales y los vectores fijos, /U, /UE, /O, /OE, /Perms y cada stream cifrado salen idénticos en la segunda corrida
Por último, SaveToStream activa DeterministicDictionaryOrder siempre que el flag reproducible esté puesto, y lo hace antes de elegir entre los layouts linealizado, de object streams y clásico, así que la salida con LinearizeOutput o UseObjectStreams también queda ordenada. Las claves se ordenan por sus bytes crudos, una clave que es prefijo de otra ordena primero, y la posición original desempata. Ese es el orden que usa el writer de diagnóstico, descrito en el artículo sobre editar un PDF a mano y repararlo después; el flag reproducible toma prestado el orden y nada del layout de texto plano de ese writer. Activar DeterministicDictionaryOrder solo es una media medida. Las claves se alinean, pero las fechas del Info y los dos strings de /ID del trailer siguen cargando el reloj, así que dos saves siguen difiriendo en los mismos cuatro lugares
Una prueba que no puede pasar de suerte
La fecha se fija en BeginDoc porque el constructor corre antes de que quien llama haya tenido ocasión de fijar la propiedad. La secuencia normal es Create, después ReproducibleOutput := True, después BeginDoc, y solo la última de esas ve el flag con su valor final. Ese orden tiene una consecuencia directa para el diseño de pruebas. Dos saves que caen dentro del mismo segundo de reloj escriben el mismo string D: por accidente, así que una prueba rápida pasa con o sin la fecha fijada, y luego falla meses después en un agente de build más lento
La corrección es barata: espere 1100 ms entre los dos saves para que el timestamp cruce garantizado un límite de segundo, corra el caso para salida plana, AES-128 y AES-256 con contraseñas reales en las variantes cifradas, y reporte el primer offset de byte que difiere en lugar de un true o false pelado. Un offset apunta a un objeto. Un booleano apunta al archivo entero
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; // recarga estricta justo después de EndDoc
if Encrypt then
begin
Pdf.OwnerPassword := 'owner';
Pdf.UserPassword := 'user';
Pdf.CryptKeyLength := KeyType; // aes128 o 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;
// Su propio helper: -1 si son iguales, si no el primer offset que difiere
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); // fuerza un segundo de timestamp distinto
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;
Llame a CheckReproducible(False, aes128), CheckReproducible(True, aes128) y CheckReproducible(True, aes256); el tipo de clave se ignora en la corrida plana. VerifySavedOutput se gana su lugar en esta prueba. Con él activo, EndDoc recarga el archivo que acaba de escribir en una instancia THotPDF fresca con la recuperación de referencias cruzadas desactivada, usando la contraseña de usuario cuando el documento está protegido, y lanza una excepción si esa carga falla. Un cambio que vuelve la salida estable e ilegible a la vez pasaría volando con un diff en verde; la recarga lo detiene
¿Un PDF cifrado reproducible sigue siendo seguro?
No. Un documento cifrado bajo ReproducibleOutput no tiene protección real, y el flag tiene que estar apagado para cualquier cosa que salga del directorio de pruebas. La file encryption key AES-256 son treinta y dos bytes de $5A. La contraseña todavía protege los wrappers /UE y /OE, pero lo que envuelven es una constante publicada, así que cualquiera que la conozca puede descifrar cada content stream sin contraseña alguna. Las sales fijas además eliminan la unicidad por archivo en la que ISO 32000-2 §7.6.4.4.7 se apoya para que una misma contraseña no produzca el mismo string /U en dos archivos. El artículo sobre la configuración de AES-256 describe lo que entregan las propiedades de cifrado con la fuente aleatoria intacta; bajo el flag reproducible nada de eso aplica. Encaje la propiedad detrás de un valor de configuración solo de pruebas, no de un ajuste que un camino de código de producción pueda alcanzar
AES-GCM se maneja distinto. Con CryptKeyLength := aesgcm (solo PDF 2.0), el componente saca el prefijo nonce de 8 bytes de la fuente aleatoria del sistema diga lo que diga ReproducibleOutput, y después cuenta hacia arriba para cada string o stream cifrado. Ese es el comportamiento correcto. GCM falla feo cuando un nonce se repite bajo la misma clave: dos ciphertexts filtran el XOR de sus plaintexts, y un atacante puede recuperar lo suficiente para falsificar tags de autenticación. Una clave fijada más un nonce fijado volverían esa repetición segura en cada archivo que la suite produzca. Los golden files cifrados usan entonces aes128 o aes256, que corren en modo CBC
El identificador es un trueque más suave. ISO 32000-1 §14.4 pretende que el segundo elemento de /ID cambie en cada modificación, y un save reproducible escribe el mismo valor en los dos slots. Como ese valor hashea el grafo de objetos, contenido distinto sigue recibiendo identificadores distintos. Un lector que clavee un caché por /ID, un almacén de anotaciones o un sidecar de datos de formulario, por ejemplo, seguirá tratando dos saves reproducibles del mismo contenido como un solo archivo. En una suite de pruebas eso es exactamente lo que usted quiere
Entradas que quedan fuera del writer
ReproducibleOutput cubre la entropía que el writer introduce por su cuenta. Unas cuantas entradas más vienen de la máquina de build o de operaciones que por definición registran un momento nuevo
- El sufijo de zona horaria. Todo string de fecha PDF termina en el offset UTC local, así que la misma fecha fija se lee
D:20260101000000+08'00'en un agente de build yD:20260101000000-05'00'en otro. Los golden files se mantienen entre corridas en una máquina, o entre máquinas que comparten zona. Si los archivos viajan entre oficinas, fije la zona horaria de los agentes - Las actualizaciones incrementales.
SaveIncrementalUpdateconserva el identificador permanente del origen y construye un identificador de modificación nuevo a partir del anterior, la ruta destino, el tick count y la hora actual. Una sección agregada es una modificación nueva por definición. Compare rewrites completos, no deltas agregados - El atajo de passthrough.
SaveLoadedDocumentnormalmente copia byte a byte un origen sin modificar y sin cifrar en lugar de serializarlo otra vez. Con el flag el archivo siempre se reescribe para que apliquen las reglas de orden e identificador, lo que hace el save más lento y jamás una copia de la entrada. Compare contra un save reproducible anterior, nunca contra el original - Los timestamps que usted agrega.
AddDocumentAttachmentsella el/Params/CreationDatey/ModDatedel archivo incrustado (ISO 32000-1 §7.11.4) con el momento de la llamada, y una firma registra su hora de firma en/M. Esas fechas describen eventos que quien llamó pidió, así que el flag las deja tranquilas. Mantenga los adjuntos y las firmas fuera de los casos a nivel de byte, o enmascare esos strings en la comparación - Las fuentes instaladas. Un subset TrueType se corta del archivo de fuente instalado en la máquina que corre la prueba. Dos agentes con releases distintas de Arial incrustan programas de glifos distintos para el mismo texto, y el prefijo del subset puede cambiar con ellos. Envíe los archivos de fuente junto con la imagen de build
Los documentos cargados siguen las mismas reglas. Fije la propiedad antes de cargar, haga sus ediciones, y guarde por SaveLoadedDocument; la salida es un rewrite normalizado que puede commitear como referencia para la próxima corrida
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.ReproducibleOutput := True; // fije antes de cargar
if Pdf.LoadFromFile('incoming.pdf') <= 0 then
raise Exception.Create('incoming.pdf did not load');
// ... ediciones bajo prueba ...
Pdf.SaveLoadedDocument('normalized.pdf'); // rewrite completo, claves ordenadas
finally
Pdf.Free;
end;
end;
Los saves idénticos byte a byte también acorralan una verificación de conformidad que parpadea. Una vez que el writer es estable, una verificación que pasa en una corrida y falla en la siguiente está reaccionando al grafo de objetos que la prueba arma, como un mismo diccionario directo entregado a dos padres, así que inspeccione la entrada de la prueba antes que el validador. Un diff estructural desde el catálogo hacia abajo muestra qué objeto se movió entre las dos corridas
Las propiedades ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput y de cifrado usadas arriba son parte del HotPDF Delphi Component, que viene tanto para Delphi como para C++Builder