HotPDF Delphi Component produce una salida PDF idéntica byte a byte entre guardados cuando la propiedad ReproducibleOutput vale True: fija el /CreationDate y el /ModDate de Info a una fecha fija, sustituye el identificador de documento basado en el reloj por un hash con semilla o derivado del contenido, reemplaza por constantes cada byte aleatorio que los caminos de cifrado AES sacarían en caso contrario, y ordena cada diccionario que serializa. El flag existe para suites de regresión y comparación de artefactos de build, no para documentos de producción, y las razones de esa frontera son la parte interesante. El escenario que impulsa la función es un test de golden file. Renderizas una factura, commiteas el PDF, y afirmas que la build de mañana producirá los mismos bytes. Nunca lo hace. El archivo abre bien en cualquier visor, el texto es idéntico, el árbol de páginas es idéntico, y el diff sigue iluminándose en cuatro o cinco sitios. Cualquiera que haya intentado meter un generador de PDF bajo un test de regresión a nivel de byte se ha dado con este muro, y el fix no es «quitar los timestamps» sino una contabilidad precisa de cada punto en el que el writer consulta algo distinto del propio documento
¿Por qué difieren dos guardados del mismo PDF?
Dos guardados del mismo documento difieren porque un writer de PDF, HotPDF incluido, consulta cuatro fuentes de entropía que no tienen nada que ver con el contenido de las páginas: el reloj de pared, el identificador de documento, el generador de números aleatorios criptográfico, y el orden en memoria de las entradas de diccionario. Cada una es legítima por sí sola. La ISO 32000-1 las quiere ahí. Simplemente convierten el archivo en una función de cuándo y dónde se escribió en lugar de qué contiene
- El reloj. El diccionario Info lleva
/CreationDatey/ModDate(ISO 32000-1 §14.3.3, Tabla 317) como cadenasD:YYYYMMDDHHmmSScon sufijo de zona horaria (§7.9.4), y el packet XMP repite el mismo instante comoxmp:CreateDateyxmp:ModifyDate. HotPDF estampa ambos desdeFCreationDate, que el constructor inicializa aNow, así que los dos guardados difieren en el segundo en que se escribieron - El identificador. El array
/IDdel trailer (ISO 32000-1 §14.4) guarda un identificador permanente y un identificador de modificación. La receta por defecto de HotPDF hashea el nombre de archivo junto con la hora actual hasta el milisegundo para el primer elemento, y hashea eso másGetTickCountpara el segundo. Dos identificadores, dos valores frescos en cada ejecución - Los bytes aleatorios. La seguridad estándar depende del identificador y de aleatoriedad genuina. Para AES-256 la clave de cifrado del archivo, las sales de validación y de clave, y cada vector de inicialización CBC se sacan de la fuente aleatoria del sistema (la ISO 32000-2 §7.6.4.4.7 exige sales aleatorias). Como
/U,/UE,/Oy/OEse calculan a partir de esos bytes, un documento cifrado cambia por completo aunque el texto plano no lo haga. Los algoritmos más antiguos meten el primer elemento/IDen la clave (ISO 32000-1 §7.6.3.3, §7.6.3.4), así que un identificador nuevo basta por sí solo para recifrar el archivo - El orden. Un diccionario PDF es un mapeo sin orden, y un writer que recorre su lista en memoria emite las claves en orden de inserción. Cualquier camino de código que construya un diccionario de recursos en una secuencia distinta, o un documento cargado que se parseó desde otra disposición, produce un archivo legal pero textualmente distinto
¿Qué fija exactamente ReproducibleOutput?
Poner ReproducibleOutput := True antes de BeginDoc o antes de SaveLoadedDocument reemplaza cada una de las cuatro fuentes por un valor fijo, y lo hace en los mismos caminos de código que en caso contrario irían a buscar el reloj o el generador aleatorio, así que no hace falta ninguna pasada de limpieza aparte. Fíjate en lo que falta de la lista de arriba: el contenido. Las fuentes, los streams de página, los datos de imagen y la tabla de cross-reference ya son deterministas para la misma entrada; el ruido vive por completo en los metadatos y en la capa de seguridad, y por eso una sola propiedad específica puede eliminarlo. La propiedad vale False por defecto y nada en la biblioteca la activa por ti
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.FileName := 'golden-invoice.pdf';
Pdf.ReproducibleOutput := True; // 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;
Dentro de BeginDoc la rama reproducible asigna FCreationDate := EncodeDate(2026, 1, 1) y siembra el identificador de documento con MD5CalcString('HotPDF-reproducible-seed') en lugar del digest de nombre de archivo más reloj. Esa única asignación cubre las dos fechas de Info y las dos fechas de XMP, porque las cuatro se renderizan desde el mismo campo. Cuando el archivo se escribe por fin, BuildDocumentIdentifiers le pide a ComputeCanonicalDocumentIdentifier el identificador del trailer: exporta todo el grafo de objetos en orden canónico, pone a cero los dígitos de cualquier cadena de fecha D: que encuentre para que los timestamps no se cuelen de vuelta por el hash, y toma el MD5 del resultado. Los dos elementos de /ID reciben ese valor. El mismo identificador derivado del contenido se usa cuando se cifra un documento cargado sin pasar nunca por BeginDoc, que es el caso de ActivateProtection sobre un archivo que abriste con LoadFromFile
Los bytes aleatorios son la sustitución menos obvia. La rutina de clave AES-256 envuelve su fuente aleatoria en un helper local que, con el flag activo, llama a FillChar(P^, Count, $5A) para la clave de cifrado del archivo de 32 bytes y para cada sal de 8 bytes, y los cifradores de cadenas y streams de AES-128 y AES-256 cambian de AESGenerateRandomIV a AESGenerateStaticIV, que rellena el vector de inicialización con 14 * (1 + I) para la posición I. Con la clave, las sales y los vectores todos fijos, /U, /UE, /O, /OE y cada stream cifrado salen idénticos en la segunda ejecución. Por último, SaveToStream activa DeterministicDictionaryOrder siempre que el flag reproducible esté puesto, y el serializador ordena entonces cada diccionario por inserción según los bytes crudos de los nombres de sus claves, prefijo más corto primero, con el índice original como desempate. Ese es el mismo 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 solo el orden, no el resto de la disposición en texto plano de ese writer
¿Por qué se colaba todavía el reloj de pared con la fecha fija?
El fix de la v2.752.2 existe porque la fecha de creación fija se decidía originalmente en el constructor, y el constructor no puede conocer una propiedad que quien llama aún no ha puesto. La secuencia de llamadas normal es Create, luego ReproducibleOutput := True, luego BeginDoc. En el momento de la construcción FReproducibleOutput sigue siendo False, así que FCreationDate recibió Now y se lo quedó. El identificador y los bytes aleatorios sí estaban bien fijados, así que los dos archivos coincidían en casi todo y discrepaban exactamente en dos cadenas de fecha y dos campos XMP. Mover la asignación a la rama reproducible de BeginDoc, junto al identificador con semilla, puso la decisión en el punto en el que la propiedad ya tiene su valor final
El test de regresión que se lo dejó pasar vale más que el fix. Dos guardados que corren dentro del mismo segundo de reloj escriben la misma cadena D: por casualidad, y la comparación de bytes pasa para un bug que falla en cualquier máquina más lenta. El test corregido duerme 1100 ms entre los dos guardados para que el timestamp del PDF cruce con garantías un límite de segundo, ejecuta el caso para salida plain, AES-128 y AES-256 con contraseñas reales en las dos variantes cifradas, y compara los dos buffers con CompareMem, reportando el primer offset que difiere en caso de fallo para que el diff apunte a un objeto concreto en lugar de a un archivo entero. Una comparación de bytes demuestra determinismo y nada más, así que mantén una aserción aparte que recargue la salida cifrada con la contraseña de usuario y lea un recuento de páginas; un cambio que deje el archivo estable e ilegible a la vez no debe colarse por el mérito de un diff en verde
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;
// en el cuerpo del test
A := SaveOnce(PathA);
TThread.Sleep(1100); // forzar un segundo distinto en el timestamp del 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');
¿Sigue siendo seguro un PDF cifrado reproducible?
No. Un documento cifrado bajo ReproducibleOutput no está protegido en ningún sentido significativo, y el flag tiene que estar apagado para cualquier cosa que salga del directorio de tests. La clave de cifrado del archivo AES-256 son treinta y dos bytes de $5A, las sales son ocho bytes de $5A, y los vectores de inicialización siguen un patrón aritmético publicado. La contraseña sigue cerrando los envoltorios /UE y /OE, pero la clave envuelta es una constante, así que cualquiera que conozca la constante puede descifrar todos los content streams sin contraseña alguna. Que las sales estén fijas elimina además la unicidad por documento en la que se apoya la ISO 32000-2 §7.6.4.4.7 para evitar que contraseñas idénticas produzcan cadenas /U idénticas entre archivos. Lee el artículo sobre la configuración de AES-256 para ver qué prometen las propiedades de cifrado cuando la fuente aleatoria está intacta; bajo el flag reproducible esas promesas quedan suspendidas
El compromiso del identificador es más sutil. La ISO 32000-1 §14.4 pretende que el segundo elemento /ID cambie en cada modificación para que las herramientas distingan un archivo actualizado de su ancestro, y un guardado reproducible escribe el mismo valor en las dos posiciones. Como ese valor es un hash del grafo canónico de objetos, dos documentos con contenido distinto siguen recibiendo identificadores distintos, lo que es mejor que una constante. Pero la semilla que BeginDoc usa para derivar la clave es la misma cadena para todos los documentos en todas las máquinas, y un lector que se apoye en /ID para distinguir archivos, una caché de anotaciones o un sidecar de datos de formulario por ejemplo, confundirá todos los archivos reproducibles que hasheen igual
¿Qué no cubre el flag?
ReproducibleOutput elimina la entropía que el writer introduce por su cuenta; no puede eliminar la entropía que entra por el entorno o por caminos de código que no controla, y hay tres de esos con los que es fácil tropezar
- El sufijo de zona horaria.
_DateTimeToPdfDateañade el desfase UTC local, así queD:20260101000000+08'00'en un agente de build yD:20260101000000-05'00'en otro son bytes distintos para la misma fecha fija. La reproducibilidad se mantiene entre ejecuciones en una misma máquina, o entre máquinas que compartan zona horaria; fija la zona del agente si tus golden files viajan - Las actualizaciones incrementales.
SaveIncrementalUpdatecalcula su identificador de modificación a partir de la ruta destino,GetTickCounty la hora actual sin rama reproducible, porque una sección incremental es por definición una modificación nueva. Compara reescrituras completas, no deltas añadidos - El atajo passthrough.
SaveLoadedDocumentnormalmente copia byte a byte un archivo de origen sin modificar y sin cifrar en lugar de reserializarlo. El flag reproducible desactiva ese atajo y fuerza una reescritura completa para que apliquen las reglas de orden e identificador, lo que significa que el guardado reproducible de un archivo cargado es más lento que el de por defecto y nunca es una copia de la entrada. Compáralo contra un guardado reproducible anterior, nunca contra el original
Una lección más de la misma release, sobre lo que una comprobación que pasa demuestra y lo que no. Un fixture de test de PDF/X-6 llamaba a CharProcs.DeleteValue('A'), que liberaba un stream de glifos guardado directamente, y luego reinsertaba el mismo puntero, y por separado le pasaba un mismo objeto ExtGState directo tanto a un diccionario de recursos como a un pattern. El validador de conformidad pasaba de forma intermitente con ese use-after-free y esa doble propiedad porque estaba leyendo lo que la memoria liberada contuviera en ese momento. Cuando una comprobación estructural parpadea, mira la propiedad de la entrada del test antes de mirar el validador. La salida reproducible abarata esa disciplina: una vez que dos guardados son idénticos byte a byte, la única fuente de parpadeo que queda es el propio grafo de objetos, y un diff estructural desde el catálogo hacia abajo lo encontrará
Las propiedades ReproducibleOutput, DeterministicDictionaryOrder y de cifrado descritas aquí vienen en el HotPDF Delphi Component estándar para Delphi y C++Builder, y el mismo flag mueve el corpus de regresión de la propia biblioteca, así que el comportamiento que obtienes en una suite de tests es el comportamiento con el que se prueba el componente