Artículo técnico

ID de PDF determinista en Delphi para builds reproducibles

losLab PDF Library puede producir una salida PDF idéntica byte a byte para una entrada idéntica en cuanto llamas a SetDeterministicDocumentID(1). Por defecto, el array /ID del trailer es un resumen 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 nadie se ponga a buscarlo. La plantilla no ha cambiado, el registro de entrada no ha cambiado, las fuentes tipográficas no han cambiado, 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 byte se disparan en archivos que nadie ha tocado. Si sigues el diff hasta los bytes reales, casi siempre son el mismo puñado de dígitos hexadecimales alojados en el trailer del archivo

Para qué sirve el array ID del trailer

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

La especificación no dice nada sobre cómo calcular el valor. La recomendación es un resumen de elementos 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 único el resultado. Esa es exactamente la propiedad que quieres para la identidad y exactamente la propiedad que destruye la reproducibilidad, por lo que esto debe ser un interruptor explícito y no 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 el resto de bytes del archivo sean idénticos. El coste posterior es real: un sistema de build que indexa artefactos por hash nunca puede reutilizar un paso de generación de PDF, un almacén de objetos con deduplicación conserva una copia por build en lugar de una copia por documento, y un revisor que examina un diff binario tiene que demostrar 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 diseño descrito en las notas sobre flujos de objetos y flujos de referencias cruzadas

Cambiar a un identificador reproducible

El modo determinista es opcional, por documento, y está desactivado por defecto, de modo que la salida existente no cambia hasta que lo solicitas. SetDeterministicDocumentID acepta 0 o 1 y devuelve 1 cuando el valor se ha aceptado, 0 para cualquier valor fuera de rango; GetDeterministicDocumentID informa del estado actual. SetDocumentIDSeed proporciona una cadena de semilla explícita que prevalece sobre todo lo demás, y pasar una semilla vacía revierte a la semilla derivada. GetDocumentFileID lee /ID[0] después del guardado para que puedas registrarlo o comprobarlo mediante una aserción

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 renovación ocurre en el momento del guardado, no cuando activas el indicador, así que habilitar el modo determinista tarde en la construcción de un documento sigue surtiendo efecto. Eso también significa que una semilla cambiada llega al archivo en el siguiente guardado completo: fijas la semilla A, guardas, fijas la semilla B, guardas, 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 ninguna?

Sin una semilla explícita, losLab PDF Library deriva una a partir del estado del documento que debería ser invariable entre regeneraciones idénticas: la cabecera 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 de nombre se toman tal cual, otros tipos de objeto aportan su forma serializada, y todo el conjunto se convierte mediante hash en las cadenas /ID. La consecuencia importante es que CreationDate y ModDate forman parte del diccionario de información y, por tanto, forman parte de la semilla por diseño. Dos ejecuciones solo obtienen el mismo identificador cuando realmente 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 suele pillar a la gente. Un /ID determinista por sí solo no hace que el archivo sea idéntico byte a byte, porque la ruta de guardado sella ModDate con la hora actual a menos que quien llama lo haya fijado explícitamente. Fijar la clave 8 marca el valor como suministrado por el llamador y suprime ese sellado. Si quieres un archivo reproducible y no solo un identificador reproducible, trata las marcas de tiempo de metadatos como entradas del build: derívalas del registro de origen o de una época fija, nunca de Now

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

Porque /ID[0] no es solo metadato en un documento cifrado, es material de clave. El Algoritmo 2 de la norma ISO 32000-1 §7.6.3.3 introduce el primer elemento del identificador de archivo en el cálculo de la clave de cifrado del 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 comprueba al abrir el archivo, y la clave del archivo se deriva y se almacena en caché cuando llamas a Encrypt o cuando se carga un documento cifrado, ambas cosas antes del guardado. Reescribir el identificador durante el guardado emitiría, por tanto, un archivo estructuralmente válido cuya comprobación /U falla al reabrirlo: no una corrupción sutil, sino un documento que nadie puede abrir, tú incluido. Por eso la renovación determinista está restringida a documentos que no llevan estado de cifrado, y por eso un documento cifrado conserva el /ID que ya tuviera, con o sin modo determinista, y el ajuste simplemente no tiene efecto en esa ruta. La gestión de revisiones y la semántica de permisos relacionadas se cubren en el recorrido sobre cifrado de PDF y auditoría de permisos. Nótese también que la ruta de restauración del cifrado renueva únicamente /ID[1], el identificador de cambio, tal y como pretende el §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 a continuación, y la permanencia de /ID[0] exigida por el §14.4 es lo que le indica a un consumidor que la nueva revisión pertenece al mismo documento que la anterior. Reescribirlo rompería ese vínculo, contradiría las revisiones que ya están en el archivo e interferiría con la semántica de las firmas, ya que una firma cubre un rango de bytes de una revisión concreta de un documento concreto. Por eso losLab PDF Library renueva el identificador determinista solo en los 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 único punto de paso para la generación de identificadores

Toda la generación de /ID en losLab PDF Library pasa ahora por una única rutina interna, NewFileIDString, que es lo que hace que el interruptor determinista sea fiable en lugar de un parche sobre una sola ruta de código. La creación de documentos en blanco, la creación diferida de un array /ID ausente bajo demanda, y la ruta de restauración de la huella de cifrado la llaman todas, así que hay exactamente un único lugar por el que el reloj del sistema podría volver a colarse. También significa que futuras variantes, como un identificador derivado del contenido, son un cambio en una función y no 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 sitio, porque falla de forma escandalosa en cuanto alguna función nueva reintroduce una marca de tiempo. La reproducibilidad es una propiedad que, si no, se degrada en silencio, y una única aserción sobre dos guardados en memoria cuesta casi nada de ejecutar en cada build

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