Artículo técnico

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

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 /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. Todos se renderizan desde un único timestamp de creación que el componente toma de Now cuando se construye
  • El identificador. El array /ID del 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
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

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

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

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

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
  • 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 y D: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. SaveIncrementalUpdate conserva 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. SaveLoadedDocument normalmente 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. AddDocumentAttachment sella el /Params /CreationDate y /ModDate del 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