El PDFium Component convierte la salida PDF reproducible en una sola llamada: TPdf.SaveAsReproducible reescribe el documento de modo que guardar el mismo contenido dos veces produce archivos idénticos byte a byte. Elimina /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 hace check-in de los PDF generados en un repositorio o en una suite de tests de golden files, y cada ejecución produce un diff aunque no haya cambiado nada en ninguna página. O un archivo deduplica por SHA-256 y guarda tranquilamente 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 va 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: los timestamps del diccionario de información del documento y el identificador de archivo del trailer. Todo lo demás que emite un escritor cuidadoso, numeración de objetos, orden de objetos, maquetación del 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
Los timestamps 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 array de dos elementos de byte strings, donde el primero identifica el archivo original y el segundo cambia cuando el archivo se revisa. Los escritores típicamente derivan esas cadenas hasheando algo del archivo, y si ese algo incluye los bytes con timestamp, 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 vale 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 destino y sustituye el destino solo tras un guardado con éxito, así que una ejecución fallida no deja un PDF a medio escribir donde antes estaba la salida de tu build
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 informe merecen loguearse en CI. StrippedTimestampCount vale normalmente 2 para un documento cuyo /Info lleva ambas fechas, y 0 para uno sin diccionario /Info en absoluto. PrunedObjectCount cuenta los objetos que estaban presentes en el guardado nativo pero no son alcanzables desde el catálogo o el diccionario /Info; la reescritura reproducible los suelta, así que espera que la salida sea del mismo tamaño o menor que un guardado ordinario
Por qué quitar los timestamps no fue suficiente
La primera implementación eliminaba ambos timestamps 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 pide a PDFium un guardado nativo y 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 tanto el identificador, cambiaba en cada ejecución aunque el timestamp mismo nunca llegaba al archivo final
Es un buen ejemplo de una regla general para builds reproducibles: eliminar un valor no determinista no basta si algo se derivó antes de él. El arreglo fue dejar de llevar /ID por completo. TPdf.SaveAsReproducible escribe un trailer que contiene solo /Size, /Root y, cuando el origen 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 claves de diccionario sin corromper cadenas
Los timestamps se eliminan 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 golpearía también una cadena de /Subject o /Keywords que por casualidad contenga ese texto, y recortar bytes de una cadena literal corrompe los metadatos en silencio en lugar de fallar a gritos. El PDFium Component primero neutraliza las cadenas literales y los comentarios en una copia de escaneo del cuerpo, luego solo acepta una coincidencia donde empieza un token de nombre real, usa la gramática de objetos de PDF para encontrar dónde termina el valor de esa clave y se salta el par clave-valor entero
La consecuencia práctica es que toda otra entrada de /Info, título, autor, productor, claves personalizadas, sobrevive intacta. Solo se eliminan las dos claves definidas como fechas, y solo del diccionario al que la entrada /Info del trailer apunta de verdad
¿Qué cuesta omitir /ID para PDF cifrados y firmados?
Omitir /ID es seguro para salida PDF 1.x simple, pero descarta el cifrado y cambia lo que pasa con las firmas. En ISO 32000-1 el /ID del trailer es opcional salvo que haya una entrada /Encrypt, porque el security handler estándar mezcla la primera cadena identificadora en la clave de cifrado. Un archivo sin /ID no puede ser un archivo correctamente cifrado, así que TPdf.SaveAsReproducible ni lo intenta: cuando el trailer lleva /Encrypt, devuelve False y Report.ErrorMessage dice que el documento está cifrado. Descifra primero si tu pipeline lo permite, o deja los entregables cifrados fuera de las comprobaciones de identidad de bytes. Si apuntas a PDF 2.0, ten en cuenta que ISO 32000-2 aprieta la regla y exige /ID en todo 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 maneja el componente 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 deja de cubrir los bytes que se firmaron. La salida reproducible de un archivo firmado es por 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 pases AllowSignedDocument = True
if Pdf.SignatureCount > 0 then
// Opt-in explícito: la reescritura mueve offsets de bytes, así que hay
// que esperar que las firmas existentes 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, déjalo como está y usa la salida reproducible solo para copias derivadas, como una versión normalizada que comparas en CI. Para añadir a un archivo protegido sin perturbar lo que ya está, véase por qué un PDF cifrado no puede parchearse en silencio en Delphi
Límites que conocer antes de fiarte de la identidad de bytes
La identidad de bytes se sostiene para el mismo documento guardado por la misma build del componente, y cubre exactamente lo que el método toca. TPdf.SaveAsReproducible quita solo 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 tuviera. Eso es estable entre guardados, pero significa que las fechas XMP y de /Info ya no describen lo mismo, lo que importa para los perfiles PDF/A que exigen que ambas coincidan; el artículo sobre equivalencia de metadatos Info y XMP en PDF/A explica esas reglas. Un binario de PDFium o una versión de componente distinta puede además serializar los objetos diferente, así que fija ambos en cualquier pipeline que compare hashes entre máquinas
Dentro de esos límites, el método te da lo que necesitan los diffs de CI, los tests de golden files y los archivos con direccionamiento por contenido: misma entrada, mismos bytes. TPdf.SaveAsReproducible y TPdfReproducibleReport vienen con el PDFium Component para Delphi y C++Builder, junto al resto de su API de guardado, optimización y cumplimiento
FAQ
¿TPdf.SaveAsReproducible cambia el contenido de las páginas?
No. Elimina las claves /CreationDate y /ModDate del diccionario /Info, omite el /ID del trailer y suelta los objetos inalcanzables desde el catálogo o /Info; el contenido de página 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 orígenes cifrados
¿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 casar con sus rangos de bytes firmados. Pasar AllowSignedDocument = True confirma que aceptas ese intercambio