Artículo técnico

ID de PDF determinista en Delphi para builds reproducibles

Genera la misma factura dos veces desde la misma plantilla y los mismos datos, y compara los dos PDF. Difieren en dos sitios: las cadenas hex del array /ID del trailer y la cadena ModDate del diccionario de información del documento, ambos tomados del reloj. En losLab PDF Library, SetDeterministicDocumentID(1) más un ModDate fijo hace los archivos idénticos byte a byte, para un documento sin UUIDs de XMP por ejecución ni adjuntos

El primer intento obvio es fijar las fechas y volver a ejecutar. CreationDate y ModDate ahora coinciden, y los hashes siguen difiriendo, ahora solo en dos cadenas hex de 32 dígitos cerca del final mismo del archivo, dentro de /ID [<…> <…>]. Ese identificador se computa aparte de los metadatos, y hasta que deje de depender de la hora del guardado, una caché de build keyed por el hash del archivo nunca acierta y un store direccionable por contenido guarda un blob por ejecución en lugar de uno por documento

Para qué sirve el array ID del trailer

El /ID del trailer identifica un archivo; no hace checksum de su contenido. ISO 32000-1 §14.4 lo define como un array de dos byte strings. El primero es el identificador permanente, asignado cuando se crea el documento y destinado a sobrevivir a cada edición posterior. El segundo es el identificador cambiante, que un escritor refresca cada vez que modifica el archivo. Un sistema de gestión documental compara el par para decidir si dos archivos son revisiones de un documento o dos documentos sin relación. La Tabla 15 de la §7.5.5 hace la entrada obligatoria cuando el trailer lleva /Encrypt y opcional en otro caso, y la §14.4 nota que omitirla puede estorbar a workflows que confían en la identidad del archivo

La especificación nunca dice cómo computar el valor. Sugiere un digest MD5 sobre cosas como la hora actual, la ruta del archivo, el tamaño del archivo y los valores del diccionario de información, y el reloj es el ingrediente que hace único el resultado. La unicidad es justo lo que quieres de un marcador de identidad y justo lo que destroza la reproducibilidad. Por defecto losLab PDF Library sigue esa sugerencia en su forma más simple, un MD5 del timestamp actual, así que dos documentos creados con un segundo de diferencia reciben identificadores permanentes distintos aunque todos los demás bytes coincidan

Diagrama que compara ejecuciones de compilación Delphi idénticas cuyos ID de trailer PDF basados por defecto en el reloj difieren, frente a una salida determinista idéntica byte a byte
El id de trailer por defecto incorpora el reloj del sistema a la identidad del archivo, mientras que el modo determinista lo deriva de una semilla estable

Activar un identificador reproducible

El modo determinista es un interruptor por documento y viene apagado por defecto, así que la salida existente no cambia hasta que lo pidas. SetDeterministicDocumentID recibe 0 o 1 y devuelve 1 cuando el valor se aceptó, 0 para cualquier otra cosa; GetDeterministicDocumentID reporta el ajuste actual. SetDocumentIDSeed suministra una cadena semilla explícita, y con una semilla en su sitio el identificador es un MD5 de esa cadena, escrito en ambos elementos del array. GetDocumentFileID lee /ID[0] de vuelta tras el guardado, útil para logging o para una aserción de test

var
  Lib: TPDFlib;
  FileID: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetDeterministicDocumentID(1);        // justo después de Create
    Lib.SetDocumentIDSeed('invoice-4471-rev3');
    Lib.SetInformation(7, 'D:20260101000000Z');   // CreationDate
    Lib.SetInformation(8, 'D:20260101000000Z');   // ModDate
    Lib.SetOrigin(1);
    Lib.DrawText(100, 700, 'Invoice 4471');
    Lib.SaveToFile('invoice.pdf');
    FileID := Lib.GetDocumentFileID;          // el mismo valor en cada ejecución
  finally
    Lib.Free;
  end;
end;

El identificador se computa al guardar. Activar el interruptor tarde en la construcción de un documento sigue funcionando, y una semilla cambiada llega al archivo en el siguiente guardado completo: guarda con la semilla A, guarda otra vez con la semilla B, y las dos salidas llevan identificadores distintos, mientras que volver a la semilla A te devuelve el valor original

Activa el interruptor inmediatamente después de crear el objeto, eso sí. Las fuentes, imágenes, estados de gráficos y otros recursos de página reciben nombres en el diccionario de recursos de la página, y mientras el modo determinista está activo esos nombres vienen de un contador. Cualquier cosa añadida antes del interruptor conserva un nombre generado al azar, lo que deja el identificador estable pero cambia unos pocos bytes de los content streams en cada ejecución

¿Por qué el identificador sigue cambiando cuando no hay semilla?

Porque el diccionario de información forma parte del hash. Deja la semilla vacía, o pasa una cadena vacía a SetDocumentIDSeed para limpiar una, y losLab PDF Library deriva el identificador del propio archivo: pasa el cuerpo serializado por SHA-256 mientras lo escribe y usa los primeros 16 bytes del digest para ambos elementos de /ID. Cualquier cambio en el contenido de página cambia el identificador, que es justo la gracia. La pega es que CreationDate y ModDate viven en ese cuerpo también, y las rutinas de guardado estampan ModDate con la hora actual salvo que quien llama la haya fijado

Flujo de decisión de PDF Library for Delphi que muestra SetDocumentIDSeed con prioridad sobre la semilla derivada del estado del documento usada para IDs PDF deterministas
Una semilla explícita prevalece sobre todo lo demás, mientras que la vía derivada aplica hash a entradas del diccionario de información, incluidas CreationDate y ModDate
Lib.SetDeterministicDocumentID(1);
Lib.SetDocumentIDSeed('');   // deriva el /ID de los bytes guardados
// El hash del cuerpo cubre el diccionario Info, así que las fechas deben
// ser entradas de build. La clave 8 también evita que el guardado estampe ModDate.
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');

La clave 8 hace doble trabajo aquí, y es la línea que la gente más suele dejar fuera. Fijar ModDate mediante SetInformation marca la fecha como suministrada por quien llama, y desde entonces SaveToFile y SaveToString la dejan tranquila. Sin eso, cada guardado escribe un timestamp fresco, el cuerpo hasheado cambia, y el identificador derivado del contenido cambia con él. Toma las fechas del registro fuente o de un epoch fijo. Now no pinta nada en una build reproducible

Unas cuantas cosas se dejan fuera del hash a propósito. Los nombres de recursos generados al azar y el UUID de una entrada XMP xmpMM:InstanceID se normalizan antes de hashear, así que dos ejecuciones que difieran solo en esos tokens reciben el mismo /ID. Los bytes del archivo siguen difiriendo en esos sitios, así que un documento estilo PDF/A con un UUID de instancia fresco por ejecución te da un identificador reproducible pero no un archivo idéntico byte a byte. Los adjuntos incrustados son otra fuente de deriva: AddAssociatedFileFromString estampa el /ModDate del propio adjunto con la hora actual, y esa fecha se hashea como cualquier otro byte del cuerpo

Semilla explícita o hash de contenido

Para artefactos de build dejaríamos la semilla vacía. Un identificador derivado del contenido cambia exactamente cuando cambian los bytes, así que una caché keyed por él nunca puede servir un archivo obsoleto, y no consigues una segunda clave que mantener sincronizada con los datos. Una semilla explícita encaja un trabajo distinto: un archivo de destino aguas abajo que trata /ID[0] como identidad del documento y espera el mismo identificador entre regeneraciones de la misma factura, incluso después de que la plantilla cambie. Eso se acerca mejor a la intención de la §14.4, al precio de que dos archivos visiblemente distintos puedan compartir identificador

Una consecuencia de la vía de contenido merece una decisión deliberada. Cuando cargas un PDF existente y lo guardas completo con el modo determinista activo, el /ID entrante se descarta y el nuevo depende solo de lo que se escribe. Dos archivos fuente que difieran solo en sus identificadores de trailer salen con el mismo /ID. Eso es lo que quiere un paso de build que normaliza. Si estás editando el documento de un cliente y su identidad tiene que sobrevivir, deja el modo determinista apagado para ese trabajo o guárdalo incrementalmente

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

Porque en un documento cifrado /ID[0] es material de clave. ISO 32000-1 §7.6.3.3 Algoritmo 2 introduce el primer elemento del identificador de archivo en el cálculo de la clave de cifrado del security handler estándar en las revisiones 2 a 4, junto a la contraseña con padding, el valor /O y los bits de permisos. Esa clave produce luego la cadena /U que un lector comprueba al abrir. La clave se deriva cuando llamas a Encrypt o cargas un archivo cifrado, antes de cualquier guardado, así que sustituir el identificador durante el guardado produciría un archivo bien formado cuya comprobación de contraseña falla para todos, incluido tú

losLab PDF Library por eso refresca el identificador determinista solo en guardados completos sin cifrar. Un documento cifrado conserva el /ID que ya tuviera, y el interruptor no tiene efecto sobre esa ruta. Reabrir ese archivo con la contraseña de usuario funciona como siempre. No metas salida cifrada en una comparación de bytes tampoco: el cifrado AES bajo la §7.6.2 empieza cada cadena y stream cifrado con un vector de inicialización aleatorio de 16 bytes, así que dos cifrados del mismo documento nunca coinciden byte por byte. Si necesitas ahí una comprobación de regresión, descifra ambos y compara lo que devuelvan. El manejo de revisiones y los bits de permisos están tratados con más profundidad en el paseo por auditoría de cifrado y permisos PDF

PDF Library for Delphi: Mapa de salvaguardas del ID PDF determinista que cubre guardados completos, documentos cifrados, actualizaciones incrementales y la ruta de restauración del cifrado
Solo los guardados completos sin cifrar pueden renovar el id, ya que los archivos cifrados tratan /ID[0] como material de clave y las revisiones añadidas deben permanecer enlazadas

Los guardados incrementales conservan el identificador original

Una actualización incremental deja en su sitio cada byte anterior del archivo y añade una revisión nueva después. La permanencia de /ID[0] es lo que dice a quien consume que la revisión añadida pertenece al mismo documento que las anteriores. Reescribirlo contradiría las revisiones ya presentes en el archivo y socavaría los workflows de firma, ya que una firma cubre un rango de bytes de una revisión concreta. El modo append nunca toca el identificador en modo determinista, lo que mantiene intacto el comportamiento descrito en el artículo sobre actualizaciones incrementales de PDF y append a stream

Mantener la salida reproducible en el tiempo

Cada cambio posterior al generador es una oportunidad de meter un valor de reloj de vuelta en el archivo: un watermark que imprime la hora de generación, una ruta de código que adjunta un archivo, una opción de guardado distinta. Las opciones de guardado importan tanto como el contenido: empaquetar objetos en compressed object streams produce una disposición de archivo completamente distinta de la tabla de referencias cruzadas clásica, como explican las notas sobre object streams y cross reference streams, así que mantén esos ajustes fijos por pipeline. La defensa más barata es un test que construya el mismo documento dos veces en memoria y compare los bytes

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;

// Dos builds independientes deben dar una sola secuencia de bytes
if BuildQuote('quote-8812') = BuildQuote('quote-8812') then
  WriteLn('reproducible')
else
  WriteLn('nondeterminism leaked into the output');

El mismo arnés puede comparar una ejecución serial y una paralela de RecompressStreams sobre la misma fuente: cárgala en dos instancias de TPDFlib, activa los IDs deterministas y fija ModDate en ambas, pon SetOptimizationWorkers(1) en una y un recuento de workers mayor en la otra, y compara los dos resultados de SaveToString. Con el reloj fuera de ambos archivos, cualquier diferencia que quede viene de la recompresión misma

Los identificadores de trailer reproducibles, el control de metadatos y el guardado incremental forman todos parte de losLab PDF Library para Delphi y C++Builder, cuya referencia enumera también las opciones de cifrado mencionadas aquí