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 ha movido un segundo, luego en el packet XMP y después en el /ID del trailer. Active AES-256 y la comparación se derrumba en casi cada stream, porque la clave de cifrado del archivo se acaba de generar. Enmascarar las fechas antes de comparar repara las dos primeras diferencias y ninguna de las demás. Una prueba de golden-file solo se vuelve fiable cuando cada valor que el writer toma de un sitio distinto del documento queda fijado en el momento de leerlo
Cuatro valores que nunca vinieron del documento
HotPDF Delphi Component, como cualquier writer PDF conforme, rellena cuatro clases de valor desde fuera del contenido de las páginas: el reloj, el identificador del documento, la fuente aleatoria criptográfica y el orden en memoria de las entradas de diccionario. La 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 cadenasD:YYYYMMDDHHmmSScon sufijo de zona horaria (§7.9.4), y el packet XMP repite el mismo instante comoxmp:CreateDateyxmp:ModifyDate. Todos se generan desde una única marca de tiempo 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 defecto, el primero es un MD5 del nombre de archivo junto con la hora actual hasta el milisegundo, y el segundo hashea ese valor otra vez junto con el conteo de ticks del sistema - Los bytes aleatorios. Un documento AES-256 necesita una clave de cifrado de archivo 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/Permsy un vector de inicialización de 16 bytes delante de cada cadena y stream cifrados. La 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/ID(ISO 32000-1 §7.6.3.3), así que un identificador fresco basta por sí solo para recifrar el archivo entero - 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 fue añadiendo, de modo que dos caminos de código que construyen el mismo diccionario de recursos en secuencia distinta producen un archivo legal pero textualmente distinto
Todo lo demás ya se sigue de la entrada: los content streams, los datos comprimidos de imagen y los offsets de la tabla de referencias cruzadas se repiten para las mismas llamadas de dibujo. Ni siquiera el campo que parece aleatorio lo es: el prefijo de seis letras de un subset de fuente incrustada (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 ejecución. El ruido está en los metadatos y en la capa de seguridad, y por eso una sola propiedad puede quitarlo
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 de otro modo buscarían el reloj o el generador aleatorio. No hay pasada de limpieza después. La propiedad vale False por defecto, y nada en la biblioteca 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 ponerlo 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;
Bajo el flag, BeginDoc pone la marca de tiempo de creación en el 1 de enero de 2026 a las 00:00 hora local y, como tanto las fechas Info como las fechas XMP se generan desde ese único valor, una sola asignación las cubre todas. La misma rama siembra el identificador interno del documento desde 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 primero se ponen a cero los dígitos de cada cadena de fecha D:, así que una marca de tiempo no puede colarse de vuelta por el hash. Ambos elementos de /ID reciben ese valor. Un documento cargado que cifre sin llamar nunca a BeginDoc (ponga ActivateProtection después de LoadFromFile) recibe el mismo identificador derivado del contenido
Los bytes aleatorios se reemplazan por constantes. La clave de archivo AES-256, las cuatro sales y el relleno de /Perms se llenan con $5A, y cada vector de inicialización CBC, tanto en cadenas y streams AES-128 como AES-256, pasa al patrón 14 * (1 + I) para el byte I. Ese patrón no es cero a propósito. Un IV todo a ceros es la elección débil de manual para CBC, y una suite de regresión no debería normalizarlo jamás. Con clave, sales y vectores fijados, /U, /UE, /O, /OE, /Perms 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 lo hace antes de elegir entre los layout 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 en crudo, una clave que es prefijo de otra se ordena primero, y la posición original rompe empates. 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 por sí solo es un remedio a medias. Las claves se alinean, pero las fechas Info y las dos cadenas /ID del trailer siguen llevando el reloj, así que dos guardados siguen difiriendo en los mismos cuatro sitios
Una prueba que no puede pasar de suerte
La fecha se fija en BeginDoc porque el constructor corre antes de que el llamador haya tenido ocasión de poner la propiedad. La secuencia normal es Create, luego ReproducibleOutput := True, luego BeginDoc, y solo la última ve el flag con su valor definitivo. Ese orden tiene una consecuencia directa para el diseño de pruebas. Dos guardados que caen dentro del mismo segundo de reloj escriben la misma cadena D: por accidente, así que una prueba rápida pasa tanto si la fecha está fijada como si no, y luego falla meses más tarde en un agente de build más lento
El arreglo es barato: espere 1100 ms entre los dos guardados para que la marca de tiempo cruce seguro un borde de segundo, ejecute el caso para salida plana, AES-128 y AES-256 con contraseñas reales en las variantes cifradas, y reporte el offset del primer byte que difiere en lugar de un verdadero o falso desnudo. 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;
// Ayudante propio: -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 marca de tiempo 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 ejecución plana. VerifySavedOutput se gana su sitio en esta prueba. Con él activo, EndDoc recarga el archivo que acaba de escribir en una instancia THotPDF nueva con la recuperación de referencias cruzadas desactivada, usando la contraseña de usuario si el documento está protegido, y lanza una excepción si esa carga falla. Un cambio que dejara la salida estable e ilegible a la vez pasaría con un diff en verde; la recarga lo frena
¿Sigue siendo seguro un PDF cifrado reproducible?
No. Un documento cifrado bajo ReproducibleOutput no tiene protección real, y el flag debe estar apagado para todo lo que salga del directorio de pruebas. La clave de archivo AES-256 son treinta y dos bytes de $5A. La contraseña sigue guardando los envoltorios /UE y /OE, pero lo que envuelven es una constante publicada, así que quien la conozca puede descifrar cada content stream sin contraseña alguna. Las sales fijas eliminan además la unicidad por archivo de la que la ISO 32000-2 §7.6.4.4.7 depende para que una misma contraseña no produzca la misma cadena /U en dos archivos. El artículo de configuración AES-256 describe lo que prometen las propiedades de cifrado con la fuente aleatoria intacta; bajo el flag reproducible nada de eso aplica. Deje la propiedad tras un valor de configuración solo de pruebas, no al alcance de un camino de código de producción
AES-GCM se trata de otra manera. 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 por cada cadena o stream cifrado. Ese es el comportamiento correcto. GCM falla gravemente cuando un nonce se repite bajo la misma clave: dos ciphertexts filtran la XOR de sus plaintexts, y un atacante puede recuperar lo suficiente para falsificar etiquetas de autenticación. Una clave fijada más un nonce fijado harían esa repetición segura en cada archivo que produzca la suite. Los golden files cifrados usan por tanto aes128 o aes256, que corren en modo CBC
El identificador es una compensación más blanda. La ISO 32000-1 §14.4 pretende que el segundo elemento de /ID cambie en cada modificación, y un guardado reproducible escribe el mismo valor en ambas casillas. Como ese valor hashea el grafo de objetos, contenidos distintos siguen recibiendo identificadores distintos. Un lector que indexe una caché por /ID, un almacén de anotaciones o un sidecar de datos de formulario por ejemplo, seguirá tratando dos guardados reproducibles del mismo contenido como un solo archivo. En una suite de pruebas es exactamente lo que usted quiere
Entradas que se 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 registran un momento nuevo por definición
- El sufijo de zona horaria. Toda cadena de fecha PDF termina en el offset UTC local, así que la misma fecha fijada se lee
D:20260101000000+08'00'en un agente de build yD:20260101000000-05'00'en otro. Los golden files se mantienen entre ejecuciones en una máquina, o entre máquinas que comparten zona. Si los archivos viajan entre oficinas, fije la zona horaria de los agentes - Actualizaciones incrementales.
SaveIncrementalUpdateconserva el identificador permanente del origen y construye un identificador de modificación nuevo a partir del anterior, la ruta de destino, el conteo de ticks y la hora actual. Una sección añadida es una modificación nueva por definición. Compare reescrituras completas, no deltas añadidos - El atajo de passthrough.
SaveLoadedDocumentnormalmente copia un origen sin modificar y sin cifrar byte a byte en lugar de serializarlo de nuevo. Bajo el flag el archivo siempre se reescribe para que apliquen las reglas de orden e identificador, lo que hace el guardado más lento y hace que nunca sea una copia de la entrada. Compare con un guardado reproducible anterior, nunca con el original - Marcas de tiempo que añade usted.
AddDocumentAttachmentestampa 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 el llamador pidió, así que el flag las deja tranquilas. Deje adjuntos y firmas fuera de los casos a nivel de bytes, o enmascare esas cadenas en la comparación - Fuentes instaladas. Un subset TrueType se corta del archivo de fuente instalado en la máquina que ejecuta la prueba. Dos agentes con releases distintos 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 con la imagen de build
Los documentos cargados siguen las mismas reglas. Ponga la propiedad antes de cargar, haga sus ediciones y guarde con SaveLoadedDocument; la salida es una reescritura normalizada que puede subir al repositorio como referencia para la siguiente ejecución
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.AutoLaunch := False;
Pdf.ReproducibleOutput := True; // poner 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'); // reescritura completa, claves ordenadas
finally
Pdf.Free;
end;
end;
Los guardados byte a byte idénticos también acorralan a una comprobación de conformidad parpadeante. Cuando el writer es estable, una comprobación que pasa en una ejecución y falla en la siguiente está reaccionando al grafo de objetos que construye la prueba, como un 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 ejecuciones
Las propiedades ReproducibleOutput, DeterministicDictionaryOrder, VerifySavedOutput y de cifrado usadas arriba forman parte del HotPDF Delphi Component, que se distribuye tanto para Delphi como para C++Builder