Artículo técnico

Guardados PDF reproducibles byte a byte en Delphi

El PDFium Component vuelve la salida PDF reproducible una sola llamada: TPdf.SaveAsReproducible reescribe el documento de modo que guardar el mismo contenido dos veces produce archivos byte a byte idénticos. Quita /CreationDate y /ModDate del diccionario de información del documento, no escribe ningún /ID de trailer, mantiene estable el orden de objetos, y reporta qué cambió en un TPdfReproducibleReport. Los archivos cifrados se rechazan, y los firmados necesitan consentimiento explícito

La necesidad suele aparecer en un pipeline de build. Alguien registra los PDFs generados en un repositorio o en una suite de pruebas de golden-file, y cada corrida produce un diff aunque nada en ninguna página cambió. O un archivo deduplica por SHA-256 y silenciosamente almacena cincuenta copias de la misma factura, porque cada guardado estampó una hora nueva en el archivo. Un escritor de PDF que incrusta el reloj de pared está bien para una app de escritorio y es hostil a cualquier cosa que trate los bytes como identidad

¿Por qué guardar el mismo PDF dos veces produce bytes distintos?

Dos guardados de un documento sin cambios difieren por exactamente dos clases de datos: las marcas de tiempo en el diccionario de información del documento y el identificador de archivo en el trailer. Todo lo demás que emite un escritor cuidadoso, numeración de objetos, orden de objetos, layout de cross-reference, puede hacerse determinista, y la ruta de reescritura completa del PDFium Component ya lo hace: los objetos se escriben en número de objeto ascendente y la tabla xref sigue el mismo orden

Las marcas de tiempo son el culpable obvio. ISO 32000-1 §14.3.3 define /CreationDate y /ModDate en el diccionario /Info, y el guardado nativo de PDFium escribe un /ModDate fresco cada vez que serializa el documento. El identificador de archivo es el sutil. ISO 32000-1 §14.4 describe /ID como un arreglo de dos elementos de cadenas de bytes, donde el primer elemento identifica el archivo original y el segundo cambia conforme el archivo se revisa. Los escritores típicamente derivan esas cadenas hasheando algo sobre el archivo, y si ese algo incluye los bytes con marca de tiempo, el identificador hereda el no determinismo

Usar TPdf.SaveAsReproducible en Delphi

TPdf.SaveAsReproducible tiene dos sobrecargas, una que escribe a un TStream y otra a un nombre de archivo, y ambas devuelven Boolean con un out Report: TPdfReproducibleReport y un parámetro opcional AllowSignedDocument que por defecto es False. El valor de retorno refleja Report.Succeeded; cuando es False, Report.ErrorMessage dice por qué. La sobrecarga de archivo escribe a un archivo temporal junto al objetivo y reemplaza al objetivo solo después de un guardado exitoso, así que una corrida fallida no deja un PDF a medio escribir donde antes estaba la salida de su build anterior

var
  Pdf: TPdf;
  Report: TPdfReproducibleReport;
begin
  Pdf:= TPdf.Create(nil);
  try
    Pdf.FileName:= 'invoice-2026-10.pdf';
    Pdf.Active:= True;
    if Pdf.SaveAsReproducible('build\invoice-2026-10.pdf', Report) then
      Writeln(Format('%d -> %d bytes, %d objects kept, %d pruned, ' +
        '%d timestamps stripped', [Report.SourceByteCount,
        Report.OutputByteCount, Report.KeptObjectCount,
        Report.PrunedObjectCount, Report.StrippedTimestampCount]))
    else
      Writeln('Reproducible save failed: ', Report.ErrorMessage);
  finally
    Pdf.Free;
  end;
end;

Los campos del reporte valen registrarlos en CI. StrippedTimestampCount es normalmente 2 para un documento cuyo /Info carga ambas fechas, y 0 para uno sin diccionario /Info en absoluto. PrunedObjectCount cuenta los objetos que estaban presentes en el guardado nativo pero no alcanzables desde el catálogo o el diccionario /Info; la reescritura reproducible los tira, así que espere que la salida sea del mismo tamaño o más pequeña que un guardado ordinario

Por qué quitar las marcas de tiempo no fue suficiente

La primera implementación quitaba ambas marcas de tiempo y aun así producía dos archivos distintos. Las páginas coincidían, el diccionario /Info coincidía, y los trailers diferían en el segundo elemento de /ID. La causa raíz era el orden: la reescritura primero le pide a PDFium un guardado nativo, luego post-procesa esos bytes, y el constructor del trailer calculaba /ID hasheando la salida nativa. Esa salida nativa todavía contenía el /ModDate fresco, así que el hash, y por lo tanto el identificador, cambiaba en cada corrida aunque la marca de tiempo nunca llegara al archivo final

Es un buen ejemplo de una regla general para builds reproducibles: quitar un valor no determinista no basta si algo se derivó de él primero. El arreglo fue dejar de cargar /ID por completo. TPdf.SaveAsReproducible escribe un trailer que contiene solo /Size, /Root y, cuando la fuente tiene uno, /Info. Sin nada dependiente del tiempo en el trailer, dos guardados consecutivos del mismo documento comparan iguales byte a byte, que es exactamente cómo se verifica el comportamiento

function SavesAreIdentical(Pdf: TPdf): Boolean;
var
  A, B: TMemoryStream;
  RA, RB: TPdfReproducibleReport;
begin
  A:= TMemoryStream.Create;
  B:= TMemoryStream.Create;
  try
    Result:= Pdf.SaveAsReproducible(A, RA) and
      Pdf.SaveAsReproducible(B, RB) and
      (A.Size = B.Size) and CompareMem(A.Memory, B.Memory, A.Size);
  finally
    B.Free;
    A.Free;
  end;
end;

Quitar llaves de diccionario sin corromper cadenas

Las marcas de tiempo se quitan con un escaneo consciente de tokens del cuerpo del objeto /Info, no con una búsqueda de texto. Un reemplazo de bytes ingenuo de /ModDate también golpearía una cadena /Subject o /Keywords que casualmente contenga ese texto, y recortar bytes de una cadena literal corrompe silenciosamente los metadatos en lugar de fallar ruidosamente. El PDFium Component primero neutraliza las cadenas literales y los comentarios en una copia de escaneo del cuerpo, luego solo acepta una coincidencia donde de verdad empieza un token de nombre, usa la gramática de objetos de PDF para hallar dónde termina el valor de esa llave, y se salta el par llave-valor completo

La consecuencia práctica es que toda otra entrada en /Info, título, autor, productor, llaves personalizadas, sobrevive intacta. Solo se quitan las dos llaves definidas como fechas, y solo del diccionario al que la entrada /Info del trailer realmente apunta

¿Qué cuesta omitir /ID para PDFs cifrados y firmados?

Omitir /ID es seguro para salida PDF 1.x plana, pero descarta el cifrado y cambia lo que sucede con las firmas. En ISO 32000-1 el /ID del trailer es opcional salvo que haya una entrada /Encrypt presente, porque el security handler estándar mezcla la primera cadena identificadora en la llave de cifrado. Un archivo sin /ID no puede ser un archivo correctamente cifrado, así que TPdf.SaveAsReproducible no lo intenta: cuando el trailer carga /Encrypt, devuelve False y Report.ErrorMessage dice que el documento está cifrado. Descifre primero si su pipeline lo permite, o mantenga los entregables cifrados fuera de los chequeos de identidad de bytes. Si apunta a PDF 2.0, note que ISO 32000-2 aprieta la regla y exige /ID en cada archivo, así que la salida reproducible encaja más naturalmente con objetivos PDF 1.x; el artículo sobre conformidad exacta de versión de PDF en Delphi cubre cómo el componente maneja los objetivos de versión

Los documentos firmados son otro problema. TPdf.SaveAsReproducible es una reescritura completa no incremental, así que los offsets de bytes se mueven y el /ByteRange de las firmas existentes ya no cubre los bytes que fueron firmados. La salida reproducible de un archivo firmado es por lo tanto una decisión deliberada de renunciar a la validez de las firmas existentes a cambio de bytes estables, que es por lo que el método lanza EPdfError cuando SignatureCount es mayor que cero salvo que usted pase AllowSignedDocument = True

if Pdf.SignatureCount > 0 then
  // Opt-in explícito: la reescritura mueve offsets de bytes, así que las
  // firmas existentes deben esperarse que fallen la verificación después
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report, True)
else
  Ok:= Pdf.SaveAsReproducible('normalized.pdf', Report);

Si el original firmado es el registro de verdad, consérvelo tal cual y use la salida reproducible solo para copias derivadas, como una versión normalizada que usted compara en CI. Para agregar a un archivo protegido sin perturbar lo que ya está ahí, vea por qué un PDF cifrado no puede parcharse silenciosamente en Delphi

Límites que conocer antes de confiar en la identidad de bytes

La identidad de bytes se cumple para el mismo documento guardado por el mismo build del componente, y cubre exactamente lo que el método toca. TPdf.SaveAsReproducible solo quita las dos fechas de /Info; un stream de metadatos XMP con su propio xmp:CreateDate o xmp:ModifyDate pasa tal cual con los bytes que el documento ya cargue. Eso es estable entre guardados, pero significa que las fechas de XMP y de /Info ya no describen lo mismo, lo que importa para los perfiles PDF/A que exigen que ambas concuerden; el artículo sobre equivalencia de metadatos PDF/A Info y XMP explica esas reglas. Un binario de PDFium distinto o una versión de componente distinta también pueden serializar los objetos de forma distinta, así que fije ambos en cualquier pipeline que compare hashes entre máquinas

Dentro de esos límites, el método le da lo que necesitan los diffs de CI, las pruebas de golden-file y los archivos direccionados por contenido: misma entrada, mismos bytes. TPdf.SaveAsReproducible y TPdfReproducibleReport vienen con el PDFium Component para Delphi y C++Builder, junto con el resto de su API de guardado, optimización y cumplimiento

Preguntas frecuentes

¿TPdf.SaveAsReproducible cambia el contenido de las páginas?

No. Quita las llaves /CreationDate y /ModDate del diccionario /Info, omite el /ID del trailer, y tira los objetos inalcanzables desde el catálogo o el /Info; el contenido de las páginas se escribe sin cambios

¿Puedo producir salida reproducible desde un PDF cifrado?

No. El cifrado depende del /ID del trailer, que la salida reproducible omite, así que el método devuelve False con un ErrorMessage explicativo para fuentes cifradas

¿Por qué un PDF firmado necesita AllowSignedDocument?

El guardado reproducible es una reescritura completa que mueve offsets de bytes, así que las firmas existentes dejan de coincidir con sus rangos de bytes firmados. Pasar AllowSignedDocument = True confirma que usted acepta ese intercambio