Artículo técnico

PDF reproducible en Delphi: salidas idénticas byte a byte

HotPDF Delphi Component produce una salida PDF idéntica byte a byte entre saves cuando la propiedad ReproducibleOutput está en True: fija el /CreationDate y el /ModDate del Info a una fecha fija, reemplaza el identificador de documento basado en el reloj por un hash sembrado o derivado del contenido, sustituye por constantes cada byte aleatorio que los caminos de cifrado AES sacarían si no, 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 golden-file test. Usted renderiza una factura, commitea el PDF y verifica que el build de mañana produzca 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 igual se enciende en cuatro o cinco lugares. Cualquiera que haya intentado poner un generador de PDF bajo una prueba de regresión a nivel de bytes se chocó con esta pared, y el fix no es "sacar los timestamps" sino una contabilidad precisa de cada lugar donde el writer consulta algo que no es el documento mismo

¿Por qué difieren dos saves del mismo PDF?

Dos saves 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 la página: el reloj de pared, el identificador del 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. ISO 32000-1 las quiere ahí. Simplemente hacen que el archivo sea una función de cuándo y dónde se escribió en lugar de qué contiene

  • El reloj. El diccionario Info lleva /CreationDate y /ModDate (ISO 32000-1 §14.3.3, Tabla 317) como strings D:YYYYMMDDHHmmSS con un sufijo de zona horaria (§7.9.4), y el packet XMP repite el mismo instante como xmp:CreateDate y xmp:ModifyDate. HotPDF sella los dos desde FCreationDate, que el constructor inicializa en Now, así que los dos saves difieren en el segundo en que se escribieron
  • El identificador. El array /ID del 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ás GetTickCount para el segundo. Dos identificadores, dos valores frescos en cada corrida
  • Los bytes aleatorios. La seguridad estándar depende del identificador y de aleatoriedad genuina. Para AES-256, la file encryption key, las sales de validación y de clave y cada vector de inicialización CBC se sacan de la fuente aleatoria del sistema (ISO 32000-2 §7.6.4.4.7 exige sales aleatorias). Como /U, /UE, /O y /OE se calculan todos a partir de esos bytes, un documento cifrado cambia por completo incluso cuando el texto plano no. Los algoritmos más viejos pliegan el primer elemento de /ID dentro de la clave (ISO 32000-1 §7.6.3.3, §7.6.3.4), así que un identificador fresco por sí solo alcanza para recambiar la clave del 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 arme un diccionario de recursos en una secuencia distinta, o un documento cargado que se parseó desde un layout distinto, produce un archivo legal pero textualmente diferente
Las cuatro fuentes de entropía que hacen que dos saves de HotPDF de un mismo documento difieran: FCreationDate, sellado desde Now, alimenta las fechas D: y el packet XMP, el /ID del trailer hashea nombre de archivo, reloj y GetTickCount, AES saca el material de clave de la fuente aleatoria del sistema, y los diccionarios se serializan en orden de inserción en memoria
Cada fuente es legítima por sí sola y ISO 32000-1 las quiere ahí, pero juntas convierten el archivo en una función de cuándo y dónde se escribió en lugar de qué contiene

¿Qué fija ReproducibleOutput?

Fijar 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 si no irían a buscar el reloj o el generador aleatorio, así que no hace falta ninguna pasada de limpieza aparte. Note qué falta en la lista de arriba: el contenido. Las fuentes, los streams de página, los datos de imagen y la tabla de referencias cruzadas 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 bien apuntada puede sacarlo. La propiedad viene en False por defecto 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;     // 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 del documento con MD5CalcString('HotPDF-reproducible-seed') en lugar del digest de nombre de archivo más reloj. Esa sola asignación cubre las dos fechas del Info y las dos fechas XMP, porque las cuatro se renderizan desde el mismo campo. Cuando el archivo por fin se escribe, BuildDocumentIdentifiers le pide a ComputeCanonicalDocumentIdentifier el identificador del trailer: exporta todo el grafo de objetos en orden canónico, pone en cero los dígitos de cualquier string de fecha D: que encuentre para que los timestamps no se filtren de vuelta por el hash, y saca el MD5 del resultado. Los dos elementos de /ID reciben ese valor. El mismo identificador derivado del contenido se usa cuando un documento cargado se cifra sin pasar nunca por BeginDoc, que es el caso de ActivateProtection sobre un archivo que usted abrió 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 activado, llama a FillChar(P^, Count, $5A) para la file encryption key de 32 bytes y para cada sal de 8 bytes, y los cifradores de strings y streams AES-128 y AES-256 pasan de AESGenerateRandomIV a AESGenerateStaticIV, que llena el vector de inicialización con 14 * (1 + I) para el slot 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 corrida. Por último, SaveToStream activa DeterministicDictionaryOrder siempre que el flag reproducible esté puesto, y el serializador entonces ordena cada diccionario por inserción según los bytes crudos de los nombres de sus claves, el 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 del layout de texto plano de ese writer

Qué fija ReproducibleOutput en HotPDF: la fecha de creación pasa a ser EncodeDate 2026, 1, 1, el identificador del trailer viene de ComputeCanonicalDocumentIdentifier sobre el grafo canónico con los dígitos de D: en cero, las claves y sales de AES se llenan con bytes $5A y AESGenerateStaticIV llena cada slot, y DeterministicDictionaryOrder ordena cada diccionario
Las sustituciones corren en los mismos caminos de código que si no irían a buscar el reloj o el generador aleatorio, así que no hace falta ninguna pasada de limpieza aparte y los dos elementos de /ID reciben el mismo valor derivado del contenido

¿Por qué la fecha fija igual filtraba el reloj de pared?

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 todavía no fijó. La secuencia normal de llamadas es Create, después ReproducibleOutput := True y después BeginDoc. En el momento de la construcción FReproducibleOutput todavía está en False, así que FCreationDate recibía Now y se lo quedaba. El identificador y los bytes aleatorios sí quedaban bien fijados, así que los dos archivos coincidían en casi todo y diferían exactamente en dos strings de fecha y dos campos XMP. Mover la asignación a la rama reproducible de BeginDoc, al lado del identificador sembrado, puso la decisión en el punto donde la propiedad ya tiene su valor final

La prueba de regresión que no detectó esto vale más que el fix. Dos saves que corren dentro del mismo segundo de reloj escriben el mismo string D: por accidente, y la comparación de bytes pasa para un bug que falla en cualquier máquina más lenta. La prueba corregida duerme 1100 ms entre los dos saves para que el timestamp del PDF cruce sí o sí un límite de segundo, corre el caso para salida plana, AES-128 y AES-256 con contraseñas reales en las dos variantes cifradas, y compara los dos buffers con CompareMem, informando el primer offset que difiere en caso de falla para que el diff apunte a un objeto concreto en lugar de a un archivo entero. Una comparación de bytes prueba determinismo y nada más, así que mantenga una aserción aparte que recargue la salida cifrada con la contraseña de usuario y lea la cantidad de páginas; un cambio que vuelve el archivo estable e ilegible al mismo tiempo no debe colarse por el solo hecho de que el diff esté 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 de la prueba
A := SaveOnce(PathA);
TThread.Sleep(1100);          // fuerza otro segundo de 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');

¿Un PDF cifrado reproducible sigue siendo seguro?

No. Un documento cifrado bajo ReproducibleOutput no está protegido en ningún sentido real, y el flag tiene que estar apagado para cualquier cosa que salga del directorio de pruebas. La file encryption key de 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 todavía protege los wrappers /UE y /OE, pero la clave envuelta es una constante, así que cualquiera que conozca la constante puede descifrar cada content stream sin contraseña alguna. Que las sales estén fijas además elimina la unicidad por documento en la que ISO 32000-2 §7.6.4.4.7 se apoya para que contraseñas idénticas no den strings /U idénticos entre archivos. Lea 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 con el identificador es más sutil. ISO 32000-1 §14.4 pretende que el segundo elemento de /ID cambie en cada modificación para que las herramientas distingan un archivo actualizado de su ancestro, y un save reproducible escribe el mismo valor en los dos slots. Como ese valor es un hash del grafo de objetos canónico, dos documentos con contenido distinto igual reciben identificadores distintos, lo que es mejor que una constante. Pero la semilla que BeginDoc usa para la derivación de clave es la misma cadena para todo documento en toda máquina, y un lector que se base en /ID para distinguir archivos, por ejemplo un caché de anotaciones o un sidecar de datos de formulario, va a confundir todos los archivos reproducibles que casualmente hasheen igual

¿Qué no cubre el flag?

ReproducibleOutput saca la entropía que el writer introduce por su cuenta; no puede sacar 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. _DateTimeToPdfDate agrega el offset UTC local, así que D:20260101000000+08'00' en un agente de build y D:20260101000000-05'00' en otro son bytes distintos para la misma fecha fija. La reproducibilidad se mantiene entre corridas en una máquina, o entre máquinas que comparten zona horaria; fije la zona del agente si sus golden files viajan
  • Las actualizaciones incrementales. SaveIncrementalUpdate calcula su identificador de modificación a partir de la ruta destino, GetTickCount y la hora actual, sin rama reproducible, porque una sección incremental es por definición una modificación nueva. Compare rewrites completos, no deltas agregados
  • El atajo de passthrough. SaveLoadedDocument normalmente copia byte a byte un archivo fuente sin modificar y sin cifrar en lugar de reserializarlo. El flag reproducible desactiva ese atajo y fuerza un rewrite completo para que apliquen las reglas de orden e identificador, lo que significa que el save reproducible de un archivo cargado es más lento que el default y nunca es una copia de la entrada. Compare contra un save reproducible anterior, nunca contra el original
Dónde se detienen los saves reproducibles de HotPDF: _DateTimeToPdfDate igual agrega el offset UTC local, así que los golden files difieren entre zonas horarias, SaveIncrementalUpdate no tiene rama reproducible porque un delta es una modificación nueva, y el atajo de passthrough queda desactivado para que un archivo cargado siempre se reescriba por completo
La reproducibilidad se mantiene entre corridas en una máquina o entre máquinas que comparten zona horaria, y un save reproducible conviene compararlo contra un save reproducible anterior, nunca contra la entrada original

Una lección más de la misma release, sobre lo que una verificación en verde prueba y lo que no. Un fixture de prueba PDF/X-6 llamaba a CharProcs.DeleteValue('A'), que liberaba un stream de glifos retenido de forma directa, después reinsertaba el mismo puntero y, por separado, le entregaba un mismo objeto ExtGState directo a un diccionario de recursos y a un pattern. El validador de conformidad pasaba de forma intermitente sobre ese use-after-free y esa doble propiedad porque estaba leyendo lo que la memoria liberada tuviera en ese momento. Cuando una verificación estructural parpadea, mire la propiedad de la entrada de prueba antes de mirar el validador. La salida reproducible abarata esa disciplina: una vez que dos saves 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 va a encontrar

Las propiedades ReproducibleOutput, DeterministicDictionaryOrder y las de cifrado que se describen acá vienen en el HotPDF Delphi Component estándar para Delphi y C++Builder, y el mismo flag maneja el corpus de regresión propio de la librería, así que el comportamiento que usted obtiene en una suite de pruebas es el comportamiento con el que se prueba el componente