HotPDF Component aplica un stream FDF /Differences desde Delphi mediante THPDFHeadlessDocument.SaveFDFDifferencesToFile: el método comprueba que el PDF cargado todavía coincide con una revisión base que identificáis por longitud exacta en bytes y SHA-256, añade los bytes de revisión acumulados exactamente una vez, pasa el resultado por la admisión de permisos de cifrado y DocMDP/FieldMDP, y lo publica atómicamente. Repetir la misma entrega es un no-op byte a byte
El flujo para el que esto existe es poco glamuroso y común. Enviáis un contrato o un formulario de inspección como PDF, alguien lo rellena y lo firma en su propio visor, y lo que vuelve no es el PDF sino un fichero FDF. Los valores de campo viajan en /Fields, pero la revisión firmada en sí, los bytes que la firma CMS cubre de verdad, viajan en /Differences. Torcer el merge y o rompéis la firma o, peor, aceptáis valores de campo que ninguna firma cubrió
Qué lleva realmente un stream FDF /Differences
Un stream FDF /Differences lleva bytes en crudo de actualización incremental para el PDF objetivo, no un historial a nivel FDF. ISO 32000-1 §12.7.7.3, Tabla 243 define la entrada Differences del diccionario FDF como los cambios incrementales hechos al PDF subyacente desde que se abrió, en la misma forma que una actualización incremental PDF bajo §7.5.6: objetos nuevos y sustituidos, una sección de cross-reference y un trailer cuyo /Prev apunta hacia dentro del fichero base
El contenedor FDF en sí es lo contrario. §12.7.7.1 prohíbe actualizaciones incrementales en ficheros FDF, así que un FDF tiene un único trailer, solo objetos de generación 0 y números de objeto únicos. HotPDF Component exige exactamente eso en su parser FDF y nunca lo relaja para hacer /Differences más fácil. Los datos incrementales viven dentro de un único stream de un contenedor estrictamente no incremental, y mantener esas dos capas separadas es media tarea
Dos propiedades del stream moldean cada decisión de diseño aguas abajo:
- El stream es acumulativo: contiene cada byte añadido desde que se abrió el PDF objetivo, así que una segunda entrega de la misma sesión repite la revisión que ya confirmasteis y añade la nueva detrás
- Las entradas externas
/Fieldsy/Annotsdel mismo FDF no tienen garantizado coincidir con/Differences, así que los valores de campo que podéis leer fácilmente no son los datos que cubre la firma
Ese segundo punto merece crudeza. Si vuestro código de importación lee /V de /Fields y trata el resultado como firmado porque una firma llegó en el mismo fichero, habéis construido un canal de falsificación. HotPDF Component mantiene las rutas separadas a propósito: el importador de anotaciones FDF rechaza /Differences de plano, y solo el transporte de Differences dedicado lo toca
Ligar el append a una revisión base explícita
SaveFDFDifferencesToFile se niega a adivinar a qué revisión pertenece el FDF. El llamador pasa BaseBytes, la longitud exacta de la revisión PDF completa desde la que se abrió el formulario, y BaseSHA256, su SHA-256 hexadecimal de 64 dígitos. El método recalcula el hash de ese prefijo del documento cargado, rechaza una discrepancia con "The loaded PDF does not match the explicitly selected base", y comprueba que el prefijo termina en un punto final de revisión real cuyo startxref apunta a sus datos de cross-reference reales
La regla práctica es anotar ambos números cuando enviáis el formulario, no cuando el FDF vuelve. Un pequeño helper sobre las rutinas SHA256Init / SHA256Update / SHA256Final de HPDFCrypt basta:
uses
SysUtils, Classes, HPDFCrypt;
function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
Stream: TFileStream;
Ctx: TSHA256Context;
Digest: TSHA256Digest;
Buffer: array[0..65535] of Byte;
Remaining: Int64;
Count, I: Integer;
begin
Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
try
if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
raise EArgumentException.Create('Base length is outside the file');
SHA256Init(Ctx);
Remaining := PrefixBytes;
while Remaining > 0 do
begin
Count := SizeOf(Buffer);
if Remaining < Count then
Count := Integer(Remaining);
Stream.ReadBuffer(Buffer, Count);
SHA256Update(Ctx, Buffer, Count);
Dec(Remaining, Count);
end;
SHA256Final(Ctx, Digest);
finally
Stream.Free;
end;
Result := '';
for I := 0 to High(Digest) do
Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;
// En el envío: todo el fichero saliente es la revisión base
// BaseLength := tamaño de 'out\contract-0042.pdf' en bytes
// BaseHash := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);
El documento cargado no tiene que ser exactamente la base. Puede ser la base más las revisiones que ya confirmasteis de entregas anteriores, que es como el stream acumulativo se mantiene manejable: BaseBytes sigue nombrando el prefijo original, y todo lo que va después se trata como bytes de append previos
Aplicar las Differences con SaveFDFDifferencesToFile
THPDFHeadlessDocument.SaveFDFDifferencesToFile toma el nombre del fichero de salida, los bytes del FDF, la longitud y hash base, una selección de FDF embebido opcional y un registro de límites THPDFFDFPackageOptions, y devuelve un THPDFHeadlessFDFDifferencesResult. Dentro, abre el FDF como un THPDFFDFPackage y llama él mismo a DecodeDifferences, así que pasáis el fichero FDF, no un stream pre-decodificado. Un Selection vacío significa el FDF de nivel superior; uno no vacío entra primero en un FDF hijo embebido
uses
SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;
procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
BaseLength: Int64; const BaseHash: AnsiString);
var
Doc: THPDFHeadlessDocument;
Policy: THPDFHeadlessCMSOptions;
Selection: THPDFFDFChildSelections;
Applied: THPDFHeadlessFDFDifferencesResult;
begin
Doc := THPDFHeadlessDocument.Create;
try
Policy := THPDFHeadlessCMSOptions.Default;
Policy.TrustedCAFile := 'trust\issuing-ca.pem';
Doc.SetSignaturePolicyOptions(Policy);
Doc.LoadFromFile(CurrentPdf);
Selection := nil; // FDF de nivel superior, sin hijo embebido
Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
Selection, THPDFFDFPackageOptions.Default);
Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
[Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
finally
Doc.Free;
end;
end;
El orden de las comprobaciones importa más que la llamada en sí. Después de que el hash base pasa, el método compara los bytes de append ya confirmados, todo lo que hay entre BaseBytes y el final del fichero cargado, con el inicio de la carga decodificada. Una entrega más corta que lo que ya confirmasteis falla con "A cumulative submission cannot remove committed append bytes", y una que discrepa falla con "The cumulative Differences conflict with the committed revision". Solo el sufijo restante es nuevo, y solo ese sufijo acaba en AddedBytes
El candidato ensamblado se escribe en un fichero temporal exclusivo en el directorio objetivo, se vuelca, se reabre como fuente de revisión independiente, y se valida antes de sustituir a la salida. Los fallos de parsing, cancelación, política, presupuesto o publicación dejan tanto el documento cargado como cualquier fichero de salida existente intactos. Dos precondiciones atrapan a la gente pronto: la salida no debe ser alias de la fuente cargada, y el documento no debe tener ediciones sin guardar, porque un append opaco las descartaría en silencio ("Publish pending document edits before applying Differences")
Por qué un apply repetido es un no-op byte a byte
Un apply repetido del mismo FDF acumulativo es un no-op porque cada byte de su carga ya está en el fichero. Cuando la carga iguala exactamente los bytes de append previos, SaveFDFDifferencesToFile informa NoOp = True y AddedBytes = 0, y la salida que publica es idéntica al PDF actual. Es una garantía más fuerte que «solo-append», y es lo que hace seguros los reintentos tras un timeout de red o un worker caído
// Rev 1 ya confirmada en contract-0042-r1.pdf; el mismo FDF llega dos veces
// (Doc, Again, Fdf, Selection, BaseLength y BaseHash declarados como arriba)
Doc := THPDFHeadlessDocument.Create;
try
Doc.LoadFromFile('store\contract-0042-r1.pdf');
Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
// Again.NoOp = True, Again.AddedBytes = 0,
// y el fichero de reintento es byte-idéntico a contract-0042-r1.pdf
finally
Doc.Free;
end;
Escribid vuestras pruebas de aceptación para que casen. Afirmad igualdad de bytes completa entre la salida del reintento y la revisión actual, no meramente que el fichero no encogió. Una prueba que solo comprueba el comportamiento de solo-append pasará contenta una implementación que añade la misma revisión una segunda vez, lo que produce un PDF de aspecto válido con una sección de actualización duplicada que una auditoría DocMDP posterior tiene que explicar
Qué comprobaciones de admisión corren antes de publicar la revisión
Antes de la publicación el PDF candidato tiene que pasar comprobaciones de identidad, firma y permisos, y ninguna se fía de los campos externos del FDF. El primer elemento del /ID del trailer debe sobrevivir sin cambios, y cada campo de firma ya presente en el documento cargado debe seguir existiendo con un diccionario de firma idéntico y la misma identidad de valor de firma. Una carga Differences que suelta, sustituye o reescribe una firma existente falla con hdSignatureModificationUnsupported
Vienen luego las capas de autorización. Para un append no vacío sobre un documento cifrado, los objetos cambiados se clasifican por rol (catálogo y páginas, anotaciones, campos AcroForm, appearance streams) y se comprueban contra los bits de permiso de usuario autenticados, así que una revisión que edita anotaciones necesita el permiso de anotaciones, y así sucesivamente. Después el candidato pasa por el mismo enforcement de certificación y bloqueo de campos que HotPDF Component usa en otros sitios: una firma de certificación DocMDP (ISO 32000-1 §12.8.2.2) limita qué clases de cambio pueden hacer las revisiones posteriores, y los bloqueos FieldMDP (§12.8.2.4) protegen campos nombrados. El análisis de revisiones DocMDP y FieldMDP en HotPDF cubre cómo se clasifican esos permisos por revisión
El manejo de errores debería separar «este FDF está malformado» de «esta revisión no está permitida», porque el segundo caso normalmente necesita una persona:
try
Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
Selection, THPDFFDFPackageOptions.Default);
except
on E: EHPDFFDFPackageError do
QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
on E: EHPDFHeadlessDocumentError do
case E.Code of
hdPermissionDenied, hdSignatureModificationUnsupported:
EscalateForReview(FdfFile, E.Message);
hdLimitExceeded:
QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
else
QuarantineFdf(FdfFile, E.Message); // base no coincidente, conflicto, endpoint erróneo
end;
end;
Lo que la operación nunca hace es tan importante como lo que comprueba. SaveFDFDifferencesToFile nunca obtiene la especificación de fichero /F del FDF, así que el PDF nombrado dentro del FDF no puede redirigir el merge a otro fichero del disco o de la red, y nunca ejecuta JavaScript ni actions. Cada entrada llega explícita: la base cargada, los bytes del FDF y el material de confianza que configurasteis
Transacciones de Windows y el fdf.differences.apply de la ABI C
El mismo transporte está disponible sin escribir código headless directamente. THPDFWindowsFormDataTransaction.ApplyFDFDifferences toma los bytes del FDF, la longitud base, el SHA-256 base, la selección, las opciones de paquete y una contraseña, delega en SaveFDFDifferencesToFile, y añade la contabilidad de bytes de la transacción, el rollback, la cancelación y el comportamiento de reintento con el mismo handle. Para llamadores que no son Delphi, la ABI C expone la operación JSON fdf.differences.apply, donde base lleva la longitud y el hash:
{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
"base": { "bytes": 673, "sha256": "..." },
"signaturePolicy": { "trustedCAPEM": "..." } }
La respuesta JSON informa baseBytes, priorAppendBytes, totalAppendBytes, addedBytes y noop, los mismos campos que THPDFHeadlessFDFDifferencesResult. Las puertas de aceptación publicadas para este transporte corren en Linux FPC 3.3.1, Windows Delphi 32 y 64 bits y Windows FPC 32 y 64 bits; cada objetivo de seguridad cubre 21 casos con 1244 comprobaciones nativas y 382 comandos de callback
Límites y trampas de fixtures que conviene conocer
El transporte de Differences es deliberadamente estrecho, y algunos de sus bordes han mordido suites de pruebas reales:
- La base debe ser una revisión PDF completa. Una longitud que aterriza a mitad de fichero, o cuyo
startxrefno apunta a sus datos de cross-reference reales, se rechaza antes de copiar un solo byte - Las fixtures de firma necesitan un
/Lockindirecto. El lector de permisos del documento cargado de Windows rechaza un campo de firma cuyo/Lockes un diccionario inline con "Signature field /Lock must be an indirect reference", aunque algunas rutas de firma toleran la forma inline, así que escribid el diccionario de bloqueo como su propio objeto - Una CA de prueba hecha con un
openssl req -x509pelado carece deCA:TRUEykeyCertSign, así que la validación de cadena falla con un error de verificación de certificado que parece un bug de la biblioteca y no lo es - Los valores externos de
/Fieldsno se fusionan con esta llamada. Si también los queréis en el documento, decid explícitamente si son informativos, y nunca los presentéis como firmados - El FDF en sí sigue siendo no incremental. Un fichero FDF con múltiples trailers, generaciones distintas de cero o números de objeto duplicados es un FDF malformado, no uno más rico
Para el lado de valores plano de FDF y XFDF, sin revisiones firmadas en la imagen, la ida y vuelta de FDF y XFDF multiselección en HotPDF y la importación y exportación de anotaciones XFDF en Delphi son los puntos de partida mejores
FAQ
¿Puedo pasar el stream Differences decodificado en lugar del fichero FDF?
No. SaveFDFDifferencesToFile parsea el FDF que pasáis como un THPDFFDFPackage, aplica la selección embebida opcional y llama internamente a DecodeDifferences. Usad THPDFFDFPackage.DecodeDifferences por su cuenta cuando queráis inspeccionar o archivar los bytes de revisión en crudo, pero entregad el fichero FDF a la llamada de apply
¿Qué pasa cuando el cliente envía un segundo FDF acumulativo más grande?
Cargad el PDF que ya contiene la primera revisión confirmada y mantened el mismo BaseBytes y BaseSHA256. El método verifica que la nueva carga empieza exactamente con los bytes ya confirmados, añade solo el sufijo nuevo y lo informa en AddedBytes, y después ejecuta la admisión completa de permisos y DocMDP/FieldMDP sobre el resultado
¿Un apply con éxito significa que los valores de /Fields están firmados?
No. ISO 32000-1 no exige que /Fields, /Annots y /Differences en un FDF coincidan, y HotPDF Component no los reconcilia. Lo firmado es lo que cubran las firmas CMS de la revisión añadida, así que leed los valores firmados del PDF fusionado, no del envoltorio FDF
El transporte FDF Differences, el motor de política de firma headless y las transacciones de form-data de Windows vienen todos en HotPDF Component para Delphi, C++Builder y Free Pascal