O HotPDF preenche formulários XFA dinâmicos em Delphi através do TXFAWidgetRuntime, uma camada de widgets neutra quanto ao anfitrião que trata cada edição de campo como uma transação: snapshot, validação, cálculo, reflow e depois publicação ou reversão completa. Executa-se numa única thread dentro do seu próprio anfitrião VCL ou FMX, não precisa de Acrobat instalado e impõe todos os limites antes de alocar qualquer coisa
O cenário é familiar a quem já entregou software de documentos para o setor público ou para seguradoras. Um formulário de sinistro ou uma declaração fiscal chega como um PDF cujo conteúdo da página é um único aviso de «Please wait... if this message is not eventually replaced», e todos os campos reais vivem num pacote XFA que só o Adobe Acrobat renderiza. Os seus utilizadores querem preenchê-lo dentro da sua aplicação. A rasterização também não resolve, porque o formulário acrescenta linhas à medida que os dados são introduzidos, e o esquema depois da terceira linha já não é o que vinha no ficheiro
O XFA dinâmico continua a ser um problema que vale a pena resolver
O XFA dinâmico persiste porque os formulários em produção sobrevivem ao formato que os transportou. A ISO 32000-1 §12.7.8 descreve o XFA como uma entrada /XFA no dicionário AcroForm que guarda um stream de pacote XDP, e a ISO 32000-2 descontinua todo o mecanismo; a descontinuação tirou-o do roteiro, não da prática, e formulários criados segundo a especificação XFA 3.3 continuam a ser emitidos e continuam legalmente vinculativos. O XFA estático pode ser reduzido a anotações de widget comuns, e o HotPDF fá-lo quando chama ApplyXFAAsAcroForm, com os compromissos cobertos em achatar formulários XFA em campos AcroForm. O XFA dinâmico é outro animal: os intervalos occur, o texto crescente e os scripts calculate fazem do conjunto de campos uma função dos dados, pelo que não há lista fixa de anotações para onde achatar enquanto o utilizador não acabar de escrever. É essa a lacuna que o TXFAWidgetRuntime preenche, mantendo o DOM XFA vivo, recalculando o esquema após cada edição aceite, e entregando ao seu anfitrião uma matriz plana de widgets posicionados para desenhar e para hit-test
O que entrega o runtime a uma aplicação anfitriã?
Entrega-lhe geometria e estado, e nada que presuma um toolkit de UI. O TXFAWidgetRuntime expõe WidgetCount e Widgets[I] como registos TXFAWidgetState que transportam ID, Name, Kind, PageIndex, Bounds em pontos PDF, Value, EditValue e as flags Focused, Editing, ReadOnly e Valid, enquanto a pintura, o desenho do cursor de texto e o encaminhamento do teclado ficam no seu código. A identidade do widget é estável e ordinal: cada widget recebe um ID da forma name[n], em que n conta as ocorrências anteriores desse nome de campo pela ordem do esquema, pelo que a segunda linha de um subformulário repetido é amount[1]. É essa identidade que sobrevive a uma reconstrução, e é nela que falam FocusWidget, BeginEdit, DispatchEvent e HitTest. Para um documento já aberto numa instância de THotPDF, o CreateLoadedXFAWidgetRuntime extrai os pacotes XDP, usa a primeira caixa de página como tamanho de página do esquema e devolve nil quando o ficheiro não traz XFA nenhum
var
Pdf: THotPDF;
Runtime: TXFAWidgetRuntime;
WidgetID: AnsiString;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.LoadFromFile('claim-dynamic.pdf');
Runtime := Pdf.CreateLoadedXFAWidgetRuntime; // nil quando não há /XFA
if Runtime = nil then
Exit;
try
for I := 0 to Runtime.WidgetCount - 1 do
Memo1.Lines.Add(Format('%s p%d [%.1f %.1f %.1f %.1f] = %s',
[string(Runtime.Widgets[I].ID), Runtime.Widgets[I].PageIndex,
Runtime.Widgets[I].Bounds.Left, Runtime.Widgets[I].Bounds.Top,
Runtime.Widgets[I].Bounds.Right, Runtime.Widgets[I].Bounds.Bottom,
string(Runtime.Widgets[I].Value)]));
// hit-test em espaço de página, o widget mais acima vence
if Runtime.HitTest(0, 120.0, 96.0, WidgetID) then
Runtime.BeginEdit(WidgetID);
finally
Runtime.Free;
end;
finally
Pdf.Free;
end;
end;
O que tem de ser atómico quando um campo é confirmado?
Tudo o que a edição pode tocar, o que é consideravelmente mais do que o valor do campo. O CommitEdit chama CaptureSnapshot antes de escrever qualquer coisa, e esse snapshot cobre quatro coisas: o DOM XFA serializado a partir de TXFADocument.SaveToBytes, a matriz completa dos registos de interação TXFAWidgetState, os contadores LastCalculationPasses e LastReflowPasses, e o Warnings.Count atual. Guardar apenas os valores dos nós é o atalho tentador e está errado, porque um script calculate ou um binding não resolvido pode chamar EnsureValueNode e materializar nós de dados que não existiam quando a edição começou; um restauro só de valores não tem forma de os remover, pelo que uma edição rejeitada deixaria resíduo estrutural permanente no pacote datasets. A sequência de commit em si é rigorosa — escrever o valor candidato, correr validate para o campo editado, correr calculate até um ponto fixo e depois reflow até o esquema estar estável — e qualquer falha em qualquer fase passa por FailAndRestore, que recarrega os bytes do snapshot num TXFADocument novo, reconstrói a lista de widgets, reaplica os estados de interação registados, repõe os contadores e trunca Warnings de volta ao comprimento que tinha no snapshot. O LastDiagnostic guarda o motivo em caso de falha, e guarda o literal XFA transaction rollback failed no caso patológico em que o próprio restauro levanta uma exceção
function EditAmount(Runtime: TXFAWidgetRuntime;
const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
Current: UnicodeString;
begin
Result := False;
if not Runtime.BeginEdit(AWidgetID) then
Exit; // só de leitura, ou widget inexistente
Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
if not Runtime.ReplaceSelection(0, Length(Current), AText) then
begin
Runtime.CancelEdit; // intervalo inválido, ou par surrogate dividido
Exit;
end;
Result := Runtime.CommitEdit; // tudo ou nada
if not Result then
// o documento, os widgets, os contadores e os avisos já voltaram ao
// estado anterior à edição; o widget com foco fica simplesmente inválido
ShowMessage(Runtime.LastDiagnostic);
end;
O ReplaceSelection merece uma nota própria, porque é onde rejeitar entrada malformada sai mais barato. Recusa uma seleção que divida um par surrogate UTF-16, recusa texto de substituição que contenha um surrogate alto ou baixo sem par, e recusa qualquer resultado mais comprido do que MaxValueChars. Apanhar isto ao nível da tecla significa que a maquinaria de transação nunca precisa de desfazer um caractere do plano astral escrito a meio
Reconstruir numa lista privada, publicar numa única troca
Uma reconstrução de widgets nunca pode ficar observável a meio, pelo que o RebuildWidgets constrói um TObjectList proprietário completamente separado e troca-o para o lugar com uma única atribuição no fim. A razão não é estética: o TXFALayoutEngine.ComputeLayout corre enquanto a reconstrução está em curso e faz callback para o código do anfitrião através da função MeasureText que forneceu, e pode levantar EXFAWidgetRuntimeError quando o limite de widgets é atingido. Se o runtime mutasse a sua lista viva no lugar, qualquer dos caminhos deixaria o anfitrião com uma lista que é em parte do esquema antigo e em parte do novo, com apontadores DataNode para um documento prestes a sofrer rollback. A convergência do reflow fica depois decidida pelo LayoutSignature, uma string construída a partir da contagem de widgets mais cada ID, índice de página e bounding box arredondados a quatro decimais: o CommitEdit reconstrói, compara assinaturas e repete até duas assinaturas consecutivas coincidirem ou o orçamento de passos se esgotar. Quando a assinatura nunca mudou de todo, o LastReflowPasses fica em 0, e é assim que distingue uma edição só de valores de uma que realmente fez o formulário crescer, e o estado de interação é transportado através de cada reconstrução pelo ID do widget, pelo que o foco e a edição em curso sobrevivem a uma inserção de linha
Porque é que um campo com binding leria o registo errado?
Porque o script correu sem um contexto de dados. Um campo com um <bind match="dataRef" ref="$record.actual"/> explícito e um campo com o nome desse mesmo nó de dados são dois widgets diferentes apontados ao mesmo valor, e um subformulário repetido com <occur max="2"/> produz vários widgets que partilham um nome e diferem apenas na linha de dados a que pertencem; avalie validação e cálculo contra a raiz do documento e cada um deles resolve this para o primeiro nó correspondente em todo o pacote datasets, pelo que a linha dois valida silenciosamente a linha um. O HotPDF evita isso guardando o DataNode resolvido em cada entrada de widget quando o esquema a produz, e passando depois esse nó por ambas as chamadas a HPDFXFAEvaluateFieldScript, tanto para xfskValidate como para xfskCalculate. O mesmo contexto decide contra que nó o EnsureValueNode cria quando um cálculo visa um binding que ainda não existe, e quando nenhum binding pode ser resolvido o commit falha de forma limpa com XFA calculation target is not bound em vez de escrever na linha errada. A semântica FormCalc por trás desses scripts faz eco do que os documentos AcroForm obtêm das ações descritas em scripts format e calculate em AcroForm, mas as regras de resolução aqui têm o âmbito do XFA e não o âmbito do nome de campo
Os orçamentos são verificados antes dos efeitos colaterais, não depois
Todos os limites no runtime são pré-condições, porque um orçamento imposto depois de a alocação já ter acontecido não é um orçamento. O TXFAWidgetRuntimeOptions.Default vem com MaxWidgets em 10000, MaxValueChars em 1048576, MaxCalculationPasses em 16 e MaxReflowPasses em 4, e as TXFAFormScriptOptions predefinidas trazem MaxOperations em 100000 com MaxElapsedMilliseconds em 500. Por baixo, o DOM XFA aplica os seus próprios TXFADOMLimits: tetos de 128 MB na entrada e na saída descomprimidas, no máximo 1024 pacotes cosidos uns aos outros, 1000000 de nós e uma profundidade de aninhamento de 256. Dois detalhes importam mais do que os números em si. Primeiro, os orçamentos de script valem para a transação inteira e não por script: o CommitEdit cria à partida um único contador de operações restantes e um prazo monotónico, e cada invocação de validate e calculate gasta desse mesmo contador e recebe apenas os milissegundos que ainda restam, pelo que um formulário com duzentos campos a calcular não pode gastar os 500 ms completos duzentas vezes. Segundo, o prazo vem de uma função MonotonicMilliseconds injetável, e é isso que torna o comportamento do tempo decorrido reprodutível numa suíte de testes em vez de um cara ou coroa num agente de builds ocupado
var
Options: TXFAWidgetRuntimeOptions;
Runtime: TXFAWidgetRuntime;
begin
Options := TXFAWidgetRuntimeOptions.Default;
Options.MaxWidgets := 2000; // predefinição 10000
Options.MaxCalculationPasses := 8; // predefinição 16
Options.MaxReflowPasses := 2; // predefinição 4
Options.ScriptOptions.Limits.MaxOperations := 20000; // transação inteira
Options.ScriptOptions.Limits.MaxElapsedMilliseconds := 200;
Options.MeasureText :=
function(const AText: UnicodeString; const AFont: TXFAFontSpec;
AMaxWidth: Double): TXFATextExtent
begin
Result := MeasureWithHostCanvas(AText, AFont, AMaxWidth);
end;
Runtime := TXFAWidgetRuntime.Create(XDPBytes, 612, 792, Options);
try
Runtime.OnLayoutChanged :=
procedure
begin
RepaintAllPages; // disparado só quando o reflow realmente moveu widgets
end;
// ... conduzir o formulário ...
finally
Runtime.Free;
end;
end;
Onde o runtime para, e porque o declara abertamente
O runtime deliberadamente não é um motor de scripting XFA geral. O DispatchEvent trata as atividades enter e exit nativamente movendo o foco, e para qualquer outra atividade que traga um script recusa-se com um diagnóstico específico e estável em vez de fingir: scripts que mencionem addInstance, removeInstance ou instanceManager devolvem XFA runtime does not support event-driven instance mutation, scripts que toquem em .presence devolvem o equivalente de presence, e todo o resto devolve XFA runtime does not support this event script. Uma recusa previsível, em que se pode ramificar, vale mais do que uma emulação parcial que funciona no seu ficheiro de exemplo e diverge no do cliente
O modelo de threading é igualmente sem rodeios: uma instância do runtime pertence a uma thread, sem bloqueio interno, porque o motor de esquema volta para trás até às callbacks de medição do anfitrião, e um lock à volta disso é um deadlock à espera de um repaint. O conteúdo rich dentro dos campos segue a mesma linha conservadora do resto da biblioteca, em que os payloads exData são tratados como descrito em texto rich e hiperligações de exData em XFA, e os widgets de assinatura e de botão voltam como ReadOnly, enquanto os tipos de UI não suportados aparecem como xwkUnsupported em vez de uma caixa de texto editável que perde dados em silêncio
Posto isto tudo junto, eis uma resposta viável para o XFA dinâmico em Delphi: manter o DOM vivo, fazer de cada edição uma transação que ou se completa por inteiro ou não deixa nada para trás, limitar cada passo, e ser explícito quanto ao que está fora do âmbito. Se está a avaliar isto para um fluxo de trabalho de sinistros, impostos ou subsídios, o runtime XFA é distribuído como parte do componente Delphi PDF HotPDF, ao lado dos caminhos de AcroForm, achatamento e renderização de que esses projetos normalmente acabam por precisar em conjunto