O HotPDF Component aplica um fluxo FDF /Differences a partir do Delphi através do THPDFHeadlessDocument.SaveFDFDifferencesToFile: o método verifica que o PDF carregado ainda corresponde a uma revisão base que identifica por comprimento exato de bytes e SHA-256, acrescenta os bytes de revisão acumulados exatamente uma vez, passa o resultado por admissão de permissões de encriptação e DocMDP/FieldMDP, e publica-o atomicamente. Repetir a mesma submissão é um no-op byte a byte
O fluxo de trabalho para que isto existe não tem glamour e é comum. Envia um contrato ou um formulário de inspeção como PDF, alguém preenche-o e assina-o no seu próprio visualizador, e o que regressa não é o PDF mas um ficheiro FDF. Os valores dos campos viajam em /Fields, mas a revisão assinada em si, os bytes que a assinatura CMS realmente cobre, viaja em /Differences. Errar a fusão e ou parte a assinatura ou, pior, aceita valores de campos que nenhuma assinatura cobriu
O que transporta afinal um fluxo FDF /Differences?
Um fluxo FDF /Differences transporta bytes brutos de atualização incremental para o PDF de destino, não um histórico ao nível do FDF. A ISO 32000-1 §12.7.7.3, Tabela 243 define a entrada Differences do dicionário FDF como as alterações incrementais ao PDF subjacente feitas desde que foi aberto, na mesma forma que uma atualização incremental PDF sob §7.5.6: objetos novos e substituídos, uma secção de referências cruzadas e um trailer cujo /Prev aponta de volta para o ficheiro base
O contentor FDF em si é o oposto. O §12.7.7.1 proíbe atualizações incrementais a ficheiros FDF, pelo que um FDF tem um único trailer, só objetos de geração 0 e números de objeto únicos. O HotPDF Component impõe exatamente isso no seu parser FDF e nunca o relaxa para tornar o /Differences mais fácil. Os dados incrementais vivem dentro de um único fluxo de um contentor estritamente não incremental, e manter essas duas camadas separadas é metade do trabalho
Duas propriedades do fluxo moldam todas as decisões de design a jusante:
- O fluxo é cumulativo: contém todos os bytes acrescentados desde que o PDF de destino foi aberto, pelo que uma segunda submissão da mesma sessão repete a revisão que já comprometeu e acrescenta a nova atrás dela
- As entradas exteriores
/Fieldse/Annotsdo mesmo FDF não têm garantia de acordo com o/Differences, pelo que os valores de campos que consegue ler facilmente não são os dados que a assinatura cobre
Esse segundo ponto merece franqueza. Se o seu código de importação lê o /V de /Fields e trata o resultado como assinado porque uma assinatura chegou no mesmo ficheiro, construiu um canal de falsificação. O HotPDF Component mantém os caminhos separados de propósito: o importador de anotações FDF rejeita o /Differences de saída, e só o transporte dedicado de Differences o toca
Ligar o acréscimo a uma revisão base explícita
O SaveFDFDifferencesToFile recusa-se a adivinhar a que revisão o FDF pertence. O chamador passa BaseBytes, o comprimento exato da revisão PDF completa a partir da qual o formulário foi aberto, e BaseSHA256, o seu SHA-256 hexadecimal de 64 dígitos. O método refaz o hash desse prefixo do documento carregado, rejeita uma discrepância com "The loaded PDF does not match the explicitly selected base", e verifica que o prefixo termina num ponto final de revisão real cujo startxref aponta para os seus dados reais de referências cruzadas
A regra prática é registar ambos os números quando envia o formulário, não quando o FDF regressa. Um pequeno auxiliar sobre as rotinas SHA256Init / SHA256Update / SHA256Final em HPDFCrypt chega:
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;
// À hora do envio: o ficheiro de saída inteiro é a revisão base
// BaseLength := tamanho de 'out\contract-0042.pdf' em bytes
// BaseHash := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);
O documento carregado não tem de ser exatamente a base. Pode ser a base mais revisões que já comprometeu de submissões anteriores, que é como o fluxo cumulativo se mantém controlável: o BaseBytes continua a nomear o prefixo original, e tudo depois dele é tratado como bytes de acréscimo prévios
Aplicar as Differences com o SaveFDFDifferencesToFile
O THPDFHeadlessDocument.SaveFDFDifferencesToFile recebe o nome do ficheiro de saída, os bytes do FDF, o comprimento e hash da base, uma seleção opcional de FDF incorporado e um registo de limites THPDFFDFPackageOptions, e devolve um THPDFHeadlessFDFDifferencesResult. Por dentro, abre o FDF como um THPDFFDFPackage e chama ele próprio o DecodeDifferences, pelo que passa o ficheiro FDF, não um fluxo pré-descodificado. Um Selection vazio significa o FDF de topo; um não vazio entra primeiro num FDF filho incorporado
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 topo, sem filho incorporado
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;
A ordem das verificações importa mais do que a própria chamada. Depois de o hash da base passar, o método compara os bytes de acréscimo já comprometidos, tudo entre BaseBytes e o fim do ficheiro carregado, com o início da carga descodificada. Uma submissão mais curta do que o que já comprometeu falha com "A cumulative submission cannot remove committed append bytes", e uma que discorde falha com "The cumulative Differences conflict with the committed revision". Só o sufixo restante é novo, e só esse sufixo acaba em AddedBytes
O candidato montado é escrito num ficheiro temporário exclusivo no diretório de destino, descarregado, reaberto como fonte de revisão independente, e validado antes de substituir a saída. Falhas de análise, cancelamento, política, orçamento ou publicação deixam tanto o documento carregado como qualquer ficheiro de saída existente intocados. Duas pré-condições apanham as pessoas cedo: a saída não pode ser um alias da fonte carregada, e o documento não pode ter edições por gravar, porque um acréscimo opaco as descartaria silenciosamente ("Publish pending document edits before applying Differences")
Por que é que uma aplicação repetida é um no-op byte a byte?
Uma aplicação repetida do mesmo FDF cumulativo é um no-op porque todos os bytes da sua carga já estão no ficheiro. Quando a carga é igual aos bytes de acréscimo prévios exatamente, o SaveFDFDifferencesToFile reporta NoOp = True e AddedBytes = 0, e a saída que publica é idêntica ao PDF atual. Isso é uma garantia mais forte que "apenas de acréscimo", e é o que torna as repetições após um timeout de rede ou um worker crashado seguras
// A Rev 1 já está comprometida em contract-0042-r1.pdf; o mesmo FDF chega duas vezes
// (Doc, Again, Fdf, Selection, BaseLength e BaseHash declarados como acima)
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,
// e o ficheiro de repetição é byte-idêntico ao contract-0042-r1.pdf
finally
Doc.Free;
end;
Escreva os seus testes de aceitação para casar. Afirme igualdade total de bytes entre a saída da repetição e a revisão atual, não apenas que o ficheiro não encolheu. Um teste que só verifica comportamento apenas-de-acréscimo passará contente uma implementação que acrescenta a mesma revisão uma segunda vez, o que produz um PDF de aparência válida com uma secção de atualização duplicada que uma auditoria DocMDP posterior tem de explicar
Que verificações de admissão correm antes de a revisão ser publicada?
Antes da publicação o PDF candidato tem de passar verificações de identidade, assinatura e permissões, e nenhuma delas confia nos campos FDF exteriores. O primeiro elemento do /ID do trailer tem de sobreviver inalterado, e cada campo de assinatura já presente no documento carregado tem de continuar a existir com um dicionário de assinatura idêntico e a mesma identidade de valor de assinatura. Uma carga Differences que abandona, substitui ou reescreve uma assinatura existente falha com hdSignatureModificationUnsupported
Seguem-se as camadas de autorização. Para um acréscimo não vazio num documento encriptado, os objetos alterados são classificados por papel (catálogo e páginas, anotações, campos AcroForm, fluxos de aparência) e verificados contra os bits de permissão do utilizador autenticado, pelo que uma revisão que edite anotações precisa da permissão de anotações, e por aí adiante. Depois o candidato passa pela mesma imposição de certificação e bloqueio de campos que o HotPDF Component usa noutros lugares: uma assinatura de certificação DocMDP (ISO 32000-1 §12.8.2.2) limita que tipos de alteração revisões posteriores podem fazer, e os bloqueios FieldMDP (§12.8.2.4) protegem campos nomeados. A análise de revisões DocMDP e FieldMDP no HotPDF cobre como essas permissões são classificadas por revisão
O tratamento de erros deve separar "este FDF está malformado" de "esta revisão não é permitida", porque o segundo caso normalmente precisa de um humano:
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); // discrepância de base, conflito, ponto final mau
end;
end;
O que a operação nunca faz é tão importante como o que verifica. O SaveFDFDifferencesToFile nunca vai buscar a especificação de ficheiro /F do FDF, pelo que o PDF nomeado dentro do FDF não pode redirecionar a fusão para outro ficheiro no disco ou na rede, e nunca executa JavaScript nem ações. Toda a entrada chega explicitamente: a base carregada, os bytes do FDF e o material de confiança que configurou
Transações Windows e o fdf.differences.apply da C ABI
O mesmo transporte está disponível sem escrever código headless diretamente. O THPDFWindowsFormDataTransaction.ApplyFDFDifferences recebe os bytes do FDF, comprimento da base, SHA-256 da base, seleção, opções de pacote e uma palavra-passe, delega no SaveFDFDifferencesToFile, e acrescenta a contabilidade de bytes, rollback, cancelamento e comportamento de repetição no mesmo handle da transação. Para chamadores não Delphi, a C ABI expõe a operação JSON fdf.differences.apply, em que o base transporta o comprimento e o hash:
{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
"base": { "bytes": 673, "sha256": "..." },
"signaturePolicy": { "trustedCAPEM": "..." } }
A resposta JSON reporta baseBytes, priorAppendBytes, totalAppendBytes, addedBytes e noop, os mesmos campos do THPDFHeadlessFDFDifferencesResult. Os portões de aceitação publicados para este transporte correm em Linux FPC 3.3.1, Windows Delphi 32-bit e 64-bit e Windows FPC 32-bit e 64-bit; cada alvo de segurança cobre 21 casos com 1,244 verificações nativas e 382 comandos de callback
Limites e armadilhas de fixtures que valem a pena conhecer
O transporte Differences é deliberadamente estreito, e algumas das suas arestas já morderam suites de teste reais:
- A base tem de ser uma revisão PDF completa. Um comprimento que caia a meio do ficheiro, ou cujo
startxrefnão aponte para os seus dados reais de referências cruzadas, é rejeitado antes de qualquer byte ser copiado - As fixtures de assinatura precisam de um
/Lockindireto. O leitor de permissões de documento carregado do Windows rejeita um campo de assinatura cujo/Locké um dicionário inline com "Signature field /Lock must be an indirect reference", mesmo que alguns caminhos de assinatura tolerem a forma inline, por isso escreva o dicionário de bloqueio como o seu próprio objeto - Uma CA de teste feita com um
openssl req -x509nu não temCA:TRUEnemkeyCertSign, pelo que a validação da cadeia falha com um erro de verificação de certificado que parece um bug da biblioteca e não é - Os valores exteriores
/Fieldsnão são fundidos por esta chamada. Se também os quiser no documento, decida explicitamente se são informativos, e nunca os apresente como assinados - O FDF em si permanece não incremental. Um ficheiro FDF com múltiplos trailers, gerações não nulas ou números de objeto duplicados é um FDF malformado, não um mais rico
Para o lado simples de valores de FDF e XFDF, sem revisões assinadas em jogo, a viagem de ida e volta multi-select FDF e XFDF no HotPDF e a importação e exportação de anotações XFDF no Delphi são os pontos de partida melhores
FAQ
Posso passar o fluxo Differences descodificado em vez do ficheiro FDF?
Não. O SaveFDFDifferencesToFile analisa o FDF que passa como um THPDFFDFPackage, aplica a seleção incorporada opcional e chama o DecodeDifferences internamente. Use o THPDFFDFPackage.DecodeDifferences por si quando quiser inspecionar ou arquivar os bytes brutos da revisão, mas entregue o ficheiro FDF à chamada de aplicação
O que acontece quando o cliente envia um segundo FDF cumulativo maior?
Carregue o PDF que já contém a primeira revisão comprometida e mantenha o mesmo BaseBytes e BaseSHA256. O método verifica que a nova carga começa exatamente pelos bytes já comprometidos, acrescenta só o novo sufixo e reporta-o em AddedBytes, depois corre a admissão completa de permissões e DocMDP/FieldMDP sobre o resultado
Uma aplicação bem-sucedida significa que os valores de campos em /Fields estão assinados?
Não. A ISO 32000-1 não exige que /Fields, /Annots e /Differences num mesmo FDF concordem, e o HotPDF Component não os reconcilia. O que está assinado é aquilo que as assinaturas CMS na revisão acrescentada cobrem, por isso leia os valores assinados do PDF fundido, não do invólucro FDF
O transporte FDF Differences, o motor de política de assinatura headless e as transações Windows de dados de formulário viajam todos no HotPDF Component para Delphi, C++Builder e Free Pascal