Artículo técnico

ID de PDF determinista en Delphi para builds reproducibles

losLab PDF Library puede producir una salida de PDF byte a byte idéntica para una misma entrada una vez que llamas a SetDeterministicDocumentID(1). Por defecto, el arreglo /ID del trailer es un digest MD5 del reloj del sistema, así que dos ejecuciones del mismo generador difieren al menos en esos bytes. El modo determinista deriva /ID a partir de una semilla estable en su lugar, lo que restaura los builds reproducibles

El síntoma suele aparecer en CI antes de que alguien se ponga a investigarlo. La plantilla no cambió, el registro de entrada no cambió, las fuentes no cambiaron, y el PDF generado sigue teniendo un hash distinto en cada ejecución del pipeline. Las cachés de build nunca aciertan. El almacenamiento direccionado por contenido acumula un blob nuevo por cada build nocturno. Los diffs de regresión a nivel de bytes se activan en archivos que nadie tocó. Si sigues el diff hasta los bytes reales, casi siempre es el mismo puñado de dígitos hexadecimales sentado en el trailer del archivo

Para qué sirve el arreglo ID del trailer

El /ID del trailer es un marcador de identidad de archivo, no una suma de verificación del contenido. ISO 32000-1 §14.4 lo define como un arreglo de dos cadenas de bytes: el primer elemento es el identificador permanente asignado cuando se crea el documento y está pensado para sobrevivir a cada edición posterior, y el segundo elemento es el identificador cambiante que un escritor actualiza cada vez que el archivo se modifica. Juntos permiten que un sistema decida si dos archivos son revisiones de un documento o dos documentos no relacionados. §7.5.5 hace la entrada efectivamente obligatoria en la práctica, ya que el trailer debe llevar /ID siempre que también lleve /Encrypt

Nada en la especificación dice cómo calcular el valor. La recomendación es un digest de cosas como la hora actual, la ruta del archivo, el tamaño del archivo y el diccionario de información del documento, y el reloj del sistema es el ingrediente que hace que el resultado sea único. Esa es exactamente la propiedad que quieres para la identidad y exactamente la propiedad que destruye la reproducibilidad, por lo que esto necesita ser un interruptor explícito en vez de un cambio de comportamiento silencioso

¿Por qué el mismo build produce un PDF distinto cada vez?

Porque el identificador por defecto se deriva del momento de la generación. Históricamente losLab PDF Library construía las cadenas /ID a partir de un MD5 de la marca de tiempo actual, así que un documento creado dos veces con un segundo de diferencia lleva dos identificadores permanentes distintos aunque cada otro byte del archivo sea idéntico. El costo río abajo es real: un sistema de build que indexa artefactos por hash nunca puede reutilizar un paso de PDF, un almacén de objetos deduplicador guarda una copia por build en vez de una copia por documento, y un revisor mirando un diff binario tiene que probar que el único cambio es ruido antes de confiar en el resto del diff. La generación determinista de /ID existe para eliminar ese ruido, en el mismo espíritu que el trabajo de estabilidad de layout descrito en las notas sobre flujos de objetos y flujos de referencia cruzada

Cambiar a un identificador reproducible

El modo determinista es opcional, por documento, y está desactivado por defecto, así que la salida existente no cambia hasta que lo solicites. SetDeterministicDocumentID acepta 0 o 1 y devuelve 1 cuando el valor fue aceptado, 0 para cualquier cosa fuera de rango; GetDeterministicDocumentID reporta el estado actual. SetDocumentIDSeed suministra una cadena semilla explícita que prevalece sobre todo lo demás, y pasar una semilla vacía revierte a la semilla derivada. GetDocumentFileID lee de vuelta /ID[0] después del guardado para que puedas registrarlo o afirmarlo en pruebas

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;   // identical on every run
  finally
    Lib.Free;
  end;
end;

La actualización ocurre en el momento del guardado, no cuando activas la bandera, así que habilitar el modo determinista tarde en la construcción de un documento sigue teniendo efecto. Eso también significa que una semilla cambiada llega al archivo en el próximo guardado completo: establece la semilla A, guarda, establece la semilla B, guarda, y los dos archivos llevan identificadores distintos, mientras que restaurar la semilla A restaura el valor original. Una semilla explícita es la opción correcta siempre que tu documento tenga una clave estable natural como un número de factura, una revisión de registro o un identificador de commit de git, porque desacopla el identificador de los metadatos incidentales

¿De dónde sale la semilla cuando no proporcionas una?

Sin una semilla explícita, losLab PDF Library deriva una a partir del estado del documento que debería ser invariante entre regeneraciones idénticas: el encabezado de versión del PDF, el número de páginas, y cada entrada del diccionario de información del documento. Los valores de cadena y nombre se toman literalmente, otros tipos de objeto aportan su forma serializada, y todo se hashea en las cadenas /ID. La consecuencia importante es que CreationDate y ModDate forman parte del diccionario de información y por lo tanto forman parte de la semilla por diseño. Dos ejecuciones solo obtienen el mismo identificador cuando genuinamente producen los mismos metadatos de documento

Lib.SetDeterministicDocumentID(1);
// No SetDocumentIDSeed: the seed is derived from document state,
// so the timestamps in the Info dictionary have to be pinned.
Lib.SetInformation(2, 'Quarterly Report');        // Title
Lib.SetInformation(5, 'reporting-service 4.2');   // Creator
Lib.SetInformation(7, 'D:20260101000000Z');       // CreationDate
Lib.SetInformation(8, 'D:20260101000000Z');       // ModDate
Lib.SaveToFile('report.pdf');

Fijar ModDate con la clave 8 cumple una doble función, y esta es la parte que sorprende a la gente. Un /ID determinista por sí solo no hace el archivo byte a byte idéntico, porque la ruta de guardado marca ModDate con la hora actual a menos que quien llama la haya establecido explícitamente. Establecer la clave 8 marca el valor como suministrado por quien llama y suprime esa marca. Si quieres un archivo reproducible en vez de meramente un identificador reproducible, trata las marcas de tiempo de metadatos como entradas de build: derívalas del registro fuente o de una época fija, nunca de Now

¿Por qué reescribir el ID rompe un PDF cifrado?

Porque /ID[0] no es solo metadatos en un documento cifrado, es material de clave. ISO 32000-1 §7.6.3.3 Algoritmo 2 alimenta el primer elemento del identificador de archivo al cálculo de la clave de cifrado para el manejador de seguridad estándar en las revisiones 2 a 4, junto con la contraseña rellenada, el valor /O y los bits de permiso. La clave derivada produce entonces la cadena de validación /U que un lector verifica al abrir, y la clave de archivo se deriva y se cachea cuando llamas a Encrypt o cuando se carga un documento cifrado, ambos ocurriendo antes del guardado. Reescribir el identificador durante el guardado por lo tanto emitiría un archivo estructuralmente válido cuya verificación /U falla al reabrir: no una corrupción sutil sino un documento que nadie puede abrir, incluyéndote a ti. Por eso la actualización determinista se restringe a documentos que no llevan estado de cifrado, y por eso un documento cifrado conserva el /ID que ya tenía, modo determinista o no, y la configuración simplemente no tiene efecto en esa ruta. El manejo de revisión relacionado y la semántica de permisos se cubren en el recorrido de auditoría de cifrado y permisos de PDF. Nota también que la ruta de restauración de cifrado actualiza solo /ID[1], el identificador de cambio, exactamente como pretende §14.4

Por qué los guardados incrementales conservan el identificador original

El segundo límite es el modo de anexado. Una actualización incremental deja intacto cada byte anterior del archivo y escribe una nueva revisión después de él, y la permanencia de /ID[0] en §14.4 es lo que le indica a un consumidor que la nueva revisión pertenece al mismo documento que la anterior. Reescribirlo cortaría ese vínculo, contradiría las revisiones que ya están en el archivo, e interferiría con la semántica de firma, ya que una firma cubre un rango de bytes de una revisión específica de un documento específico. losLab PDF Library por lo tanto actualiza el identificador determinista solo en guardados completos y nunca durante el modo de anexado, lo que mantiene intacta la garantía descrita en el artículo sobre actualizaciones incrementales de PDF y anexado a flujo

Un solo punto de generación de identificadores

Toda la generación de /ID en losLab PDF Library ahora pasa por una única rutina interna, NewFileIDString, que es lo que hace que el interruptor determinista sea confiable en vez de un parche sobre una sola ruta de código. La creación de documento en blanco, la creación perezosa de un arreglo /ID faltante bajo demanda, y la ruta de restauración de huella de cifrado todas la llaman, así que hay exactamente un lugar donde el reloj del sistema podría filtrarse de vuelta. También significa que futuras variantes, como un identificador derivado del contenido, son un cambio a una función en vez de una auditoría de todo el serializador

function BuildQuote(const Seed: WideString): AnsiString;
var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);
    Lib.SetDocumentIDSeed(Seed);
    Lib.SetInformation(7, 'D:20260101000000Z');
    Lib.SetInformation(8, 'D:20260101000000Z');
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Quote 8812');
    Result := Lib.SaveToString;
  finally
    Lib.Free;
  end;
end;

// Regression guard: two independent builds, one byte sequence.
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

Conecta esa comparación a tu suite de pruebas antes de confiar en la salida reproducible en cualquier otro lugar, porque falla ruidosamente en el momento en que alguna función nueva reintroduce una marca de tiempo. La reproducibilidad es una propiedad que se degrada silenciosamente de otro modo, y una sola aserción sobre dos guardados en memoria cuesta casi nada de ejecutar en cada build

La API de identificador determinista mostrada aquí viene con losLab PDF Library para Delphi y C++Builder, junto con la referencia completa de información de documento, cifrado y guardado incremental