Artigo Técnico

Runtime de formulários XFA dinâmicos em Delphi: HotPDF

O HotPDF preenche formulários XFA dinâmicos em Delphi por meio do TXFAWidgetRuntime, uma camada de widgets neutra em relação ao host que trata cada edição de campo como uma transação: snapshot, validate, calculate, reflow e depois publish ou rollback completo. Ele roda single-threaded dentro do seu próprio host VCL ou FMX, não precisa de Acrobat instalado e aplica todos os limites antes de alocar qualquer coisa

O cenário é familiar para quem já entregou software de documentos para áreas governamentais ou de seguros. Um formulário de sinistro ou uma declaração de imposto 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 em um pacote XFA que só o Adobe Acrobat renderiza. Seus usuários querem preenchê-lo dentro do seu aplicativo. E você não resolve isso rasterizando, porque o formulário cresce em linhas conforme os dados são digitados, e o layout depois da terceira linha não é o layout que veio no arquivo

Por que XFA dinâmico ainda é um problema que vale resolver

O XFA dinâmico persiste porque formulários já implantados sobrevivem ao formato que os carregou. O 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 o ISO 32000-2 reprova todo o mecanismo; a reprovação o tirou do roadmap, não do campo, e formulários escritos contra a especificação XFA 3.3 ainda são emitidos e continuam legalmente válidos. O XFA estático pode ser reduzido a anotações de widget comuns, e o HotPDF faz isso quando você chama ApplyXFAAsAcroForm, com os trade-offs cobertos em achatar formulários XFA em campos AcroForm. O XFA dinâmico é outro animal: seus intervalos occur, texto expansível e scripts calculate fazem do conjunto de campos uma função dos dados, então não há lista fixa de anotações para achatar até que o usuário termine de digitar. É essa lacuna que o TXFAWidgetRuntime preenche, mantendo o DOM XFA vivo, recalculando o layout após cada edição aceita e entregando ao seu host um array plano de widgets posicionados para desenhar e hit-test

O que o runtime entrega a um aplicativo host?

Ele entrega geometria e estado, e nada que presuma um toolkit de UI. O TXFAWidgetRuntime expõe WidgetCount e Widgets[I] como records TXFAWidgetState que carregam ID, Name, Kind, PageIndex, Bounds em pontos PDF, Value, EditValue e as flags Focused, Editing, ReadOnly e Valid, enquanto pintura, desenho do caret e roteamento de teclado ficam no seu código. A identidade do widget é estável e ordinal: cada widget recebe um ID na forma name[n], onde n conta as ocorrências anteriores desse nome de campo na ordem do layout, então a segunda linha de um subform repetido é amount[1]. É essa identidade que sobrevive a um rebuild, e é nela que FocusWidget, BeginEdit, DispatchEvent e HitTest falam. Para um documento já aberto em uma instância de THotPDF, CreateLoadedXFAWidgetRuntime extrai os pacotes XDP, toma o page box da primeira página como tamanho de página do layout e retorna nil quando o arquivo não traz XFA algum

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 ao topo 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 precisa 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 vindo de TXFADocument.SaveToBytes, o array completo de records de interação TXFAWidgetState, os contadores LastCalculationPasses e LastReflowPasses e o Warnings.Count atual. Salvar só 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 restore apenas de valores não tem como removê-los, então uma edição rejeitada deixaria resíduo estrutural permanente no pacote datasets. A sequência de commit em si é rigorosa — escreve o valor candidato, roda validate para o campo editado, roda calculate até um ponto fixo, depois reflow até o layout estabilizar — e qualquer falha em qualquer estágio passa por FailAndRestore, que recarrega os bytes do snapshot em um TXFADocument novo, reconstrói a lista de widgets, reaplica os estados de interação gravados, reseta os contadores e trunca Warnings de volta ao comprimento do 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 restore levanta exceção

O HotPDF trata o commit de um campo XFA como uma transação, capturando o DOM serializado, todo estado de widget, os contadores de passes e a contagem de warnings antes de validar, calcular e reflowar, e depois publicando ou restaurando os quatro juntos
O CommitEdit faz snapshot de quatro tipos de estado antes de escrever qualquer coisa, então um validate, calculate ou reflow que falhe não deixa resíduo estrutural
function EditAmount(Runtime: TXFAWidgetRuntime;
  const AWidgetID: AnsiString; const AText: UnicodeString): Boolean;
var
  Current: UnicodeString;
begin
  Result := False;
  if not Runtime.BeginEdit(AWidgetID) then
    Exit;                                   // somente leitura, ou widget inexistente
  Current := Runtime.Widgets[Runtime.FocusedIndex].EditValue;
  if not Runtime.ReplaceSelection(0, Length(Current), AText) then
  begin
    Runtime.CancelEdit;                     // range inválido, ou surrogate dividido
    Exit;
  end;
  Result := Runtime.CommitEdit;             // tudo ou nada
  if not Result then
    // documento, widgets, contadores e warnings já voltaram ao estado
    // pré-edição; o widget com foco é simplesmente marcado como inválido
    ShowMessage(Runtime.LastDiagnostic);
end;

O ReplaceSelection merece uma nota própria, porque é nele que rejeitar entrada malformada sai mais barato. Ele recusa uma seleção que divide um par surrogate UTF-16, recusa texto de substituição contendo um surrogate high ou low sem par e recusa qualquer resultado mais longo que MaxValueChars. Capturar isso na camada de teclas significa que a maquinaria de transação nunca precisa desfazer um caractere do plano astral escrito pela metade

Reconstruir em uma lista privada, publicar em uma única troca

Um rebuild de widgets nunca pode ser observável pela metade, então o RebuildWidgets constrói um TObjectList próprio completamente separado e o troca de lugar com uma única atribuição no final. O motivo não é estética: o TXFALayoutEngine.ComputeLayout roda enquanto o rebuild está em andamento e faz callback para o código do host pela função MeasureText que você forneceu, e pode levantar EXFAWidgetRuntimeError quando o limite de widgets é atingido. Se o runtime mutasse sua lista viva no lugar, qualquer um dos caminhos deixaria o host segurando uma lista que é em parte o layout velho e em parte o novo, com ponteiros DataNode apontando para um documento prestes a sofrer rollback. A convergência do reflow é então 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 casas decimais: o CommitEdit reconstrói, compara assinaturas e repete até duas assinaturas consecutivas coincidirem ou o orçamento de passes se esgotar. Quando a assinatura nunca mudou, LastReflowPasses fica em 0, que é como você distingue uma edição apenas de valor de uma que realmente cresceu o formulário, e o estado de interação é carregado entre rebuilds pelo ID do widget, então foco e edição em andamento sobrevivem a uma inserção de linha

O runtime XFA do HotPDF reconstrói sua lista de widgets em uma lista própria separada enquanto o layout roda e faz callback para o código de medição do host, e depois publica a lista pronta com uma única atribuição que o host não pode observar pela metade
O rebuild acontece em uma lista privada porque o ComputeLayout pode levantar exceção no meio do caminho, e o LayoutSignature decide quando dois reflows consecutivos convergiram

Por que um campo vinculado leria o registro errado?

Porque o script rodou sem um contexto de dados. Um campo com um <bind match="dataRef" ref="$record.actual"/> explícito e um campo nomeado a partir desse mesmo nó de dados são dois widgets diferentes apontando para um valor, e um subform repetido com <occur max="2"/> produz vários widgets que compartilham o 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 como o primeiro nó correspondente em todo o pacote datasets, então a linha dois valida silenciosamente a linha um. O HotPDF evita isso armazenando o DataNode resolvido em cada entrada de widget quando o layout o produz, e depois passando esse nó por ambas as chamadas de HPDFXFAEvaluateFieldScript, tanto para xfskValidate quanto para xfskCalculate. O mesmo contexto decide contra qual nó o EnsureValueNode cria quando um cálculo mira 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 ecoa o que documentos AcroForm recebem das ações descritas em scripts format e calculate do AcroForm, mas as regras de resolução aqui têm escopo XFA e não de nome de campo

Orçamentos são verificados antes dos efeitos colaterais, não depois

Todo limite no runtime é uma pré-condição, porque um orçamento aplicado depois que a alocação já aconteceu não é um orçamento. O TXFAWidgetRuntimeOptions.Default vem com MaxWidgets em 10000, MaxValueChars em 1048576, MaxCalculationPasses em 16 e MaxReflowPasses em 4, e o TXFAFormScriptOptions padrão carrega MaxOperations em 100000 com MaxElapsedMilliseconds em 500. Por baixo, o DOM XFA aplica seu próprio TXFADOMLimits: tetos de 128 MB na entrada e saída descomprimidas, no máximo 1024 pacotes costurados, 1000000 nós e profundidade de aninhamento de 256. Dois detalhes importam mais 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 semeia um único contador de operações restantes e um deadline monotônico, e cada invocação de validate e calculate consome esse mesmo contador e recebe só os milissegundos ainda restantes, então um formulário com duzentos campos calculantes não pode gastar os 500 ms completos duzentas vezes. Segundo, o deadline vem de uma função MonotonicMilliseconds injetável, e é isso que torna o comportamento de tempo decorrido reprodutível em uma suíte de testes em vez de uma moeda ao ar em um build agent ocupado

Camadas de orçamento no runtime XFA do HotPDF, dos limites de widget e valor pelos limites de operação e tempo de script até os tetos do DOM XFA, com um contador de operações e um deadline compartilhados por toda chamada em uma transação
Os orçamentos de script valem para a transação inteira e não por script, então duzentos campos calculantes não podem reivindicar 500 ms novos cada um
var
  Options: TXFAWidgetRuntimeOptions;
  Runtime: TXFAWidgetRuntime;
begin
  Options := TXFAWidgetRuntimeOptions.Default;
  Options.MaxWidgets := 2000;                              // padrão 10000
  Options.MaxCalculationPasses := 8;                       // padrão 16
  Options.MaxReflowPasses := 2;                            // padrã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;
    // ... dirigir o formulário ...
  finally
    Runtime.Free;
  end;
end;

Onde o runtime para, e por que ele diz isso em voz alta

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 toda outra atividade que carregue um script ele recusa com um diagnóstico específico e estável em vez de fingir: scripts que mencionam addInstance, removeInstance ou instanceManager retornam XFA runtime does not support event-driven instance mutation, scripts que tocam .presence retornam o equivalente de presence, e todo o resto retorna XFA runtime does not support this event script. Uma recusa previsível em que você pode ramificar vale mais que uma emulação parcial que funciona no seu arquivo de exemplo e diverge no arquivo do cliente

O modelo de threads é igualmente direto: uma instância do runtime pertence a uma thread, sem locking interno, porque o motor de layout alcança os callbacks de medição do host e um lock em volta disso é um deadlock esperando um repaint. Conteúdo rich text dentro de campos segue a mesma linha conservadora do resto da biblioteca, em que payloads exData são tratados como descrito em rich text e hyperlinks do exData XFA, e widgets de assinatura e botão voltam como ReadOnly enquanto tipos de UI não suportados aparecem como xwkUnsupported em vez de uma caixa de texto editável que silenciosamente perde dados

Juntando tudo, essa é uma resposta viável para XFA dinâmico em Delphi: manter o DOM vivo, fazer de cada edição uma transação que ou aterrissa por completo ou não deixa nada para trás, limitar cada passe e ser explícito sobre o que está fora do escopo. Se você está avaliando isso para um fluxo de sinistros, impostos ou benefícios, o runtime XFA vem como parte do componente PDF Delphi HotPDF, junto com os caminhos de AcroForm, achatar e renderização que esses projetos normalmente acabam precisando juntos