Artigo Técnico

Preenchimento em lote atômico de formulários PDF no Delphi

O PDFium Component for Delphi preenche um AcroForm atomicamente com TPdf.SetFormFieldValues: você passa de 1 a 256 requisições tipadas endereçadas por página e widget, e o componente ou aplica todo valor numa única substituição de documento ou não aplica nenhum. Quando uma requisição é inválida, o TPdfFormValuesWriteReport.FailedRequest nomeia a requisição que afundou o lote

Essa garantia importa mais nos trabalhos que ninguém vigia. Uma importação noturna empurra linhas de CRM para um formulário de sinistro, ou um serviço de impressão sobrepõe dados de apólice num template pré-impresso. Rode esses campo por campo e o primeiro valor ruim, um código que não é um export de radio válido, uma string maior que o MaxLen do campo, te deixa segurando um PDF que é meio dado antigo e meio novo. Nada crashou, então nada alertou ninguém. A API de lote existe para que uma falha seja uma recusa limpa em vez de um documento meio gravado

Por que preencher formulário campo por campo é perigoso para importação de dados?

Preencher campo por campo é perigoso porque toda gravação comita por conta própria, então um erro no meio do loop deixa um documento que não bate nem com o registro de origem nem com o template original. O padrão clássico é um loop de atribuições FormField[Index], cada uma passando pelo ambiente nativo de preenchimento de formulário do PDFium um widget por vez. Os valores de 1 a 17 aterrissam, o 18 levanta, e o chamador agora precisa saber exatamente quais valores entraram para desfazê-los

A maioria dos chamadores nunca escreve esse código de desfazer, e os que escrevem normalmente erram sutilmente: um campo compartilhado já mudou em toda página em que aparece, e estados de checkbox já viraram. Se o save roda num bloco finally, o arquivo meio preenchido vai para o disco parecendo completo. Num job de overprint isso é pior que nenhum arquivo, porque ele imprime e alguém assina

O outro problema silencioso é o foco. O setter clássico dirige o editor interativo do PDFium: ele mata qualquer foco existente, foca o widget alvo, seleciona todo o texto, substitui e mata o foco de novo. Isso é ok para um job headless, mas num viewer também encerra qualquer sessão de edição que o usuário tinha aberta noutro campo. O caminho de gravação tipado que chegou no PDFium Component 3.231.0 não passa pelo foco nativo de campos de forma alguma, e o 3.233.0 o estendeu de campos únicos a lotes entre páginas

Como o SetFormFieldValues aplica um lote numa única substituição

O TPdf.SetFormFieldValues prepara cada requisição contra um snapshot capturado do documento, grava todos os dicionários de campos numa passada, e troca o resultado pronto com uma única substituição. O documento original continua vivo até o último passo, então qualquer falha antes desse passo o deixa intocado

A sequência de cinco etapas do preenchimento atômico em lote de formulários no PDFium Component para Delphi, do preflight à verificação e troca com resultados tudo-ou-nada
cada requisição é preparada contra um snapshot e o documento vivo é tocado só na troca final verificada

A sequência explica tanto a garantia quanto o custo:

  1. Checagens de preflight rodam primeiro: o documento precisa estar ativo, a contagem de requisições precisa ser 1..256, nenhuma transação de page-object ou edit journal pode estar aberta, nenhum campo nativo pode segurar foco, e o documento precisa conceder permissão de preencher formulários ou modificar
  2. Cada requisição é validada quanto ao range de páginas e ao orçamento de entrada, e o FailedRequest acompanha o índice sendo conferido
  3. O componente captura um snapshot de edição, normaliza o AcroForm nessa cópia e aplica cada requisição em ordem, gravando /V, /I para seleções de choice e /AS para todo widget que compartilha o campo
  4. As mudanças são anexadas ao snapshot como uma única atualização incremental esparsa no sentido da ISO 32000-1 §7.5.6, então objetos não relacionados são carregados byte a byte
  5. Uma instância verificador separada carrega os bytes candidatos primeiro; só depois de abrir limpo o componente descarrega o documento vivo e carrega o novo, recuando para o snapshot original se essa carga falhar

O passo interno de substituição é a mesma máquina que o componente usa para os snapshots de edit journal dele, e é por isso que o journal de undo e recuperação de crash e uma gravação em lote não podem se sobrepor: termine ou cancele a transação do journal primeiro. Ações automáticas de ciclo de vida são suprimidas enquanto o documento é trocado, então um script de abertura de documento não pode reescrever campos nem agarrar foco no meio da substituição

Construindo um lote de requisições tipadas no Delphi

Um lote é um array dinâmico de records TPdfFormValueRequest, cada um segurando um Page com base 1, um FieldIndex local de página com base 0 e um TPdfFormValue tipado. O record de valor carrega um Kind de fvkText, fvkChoice, fvkCheckBox ou fvkRadioButton, mais os membros Text, ChoiceIndices e Checked usados conforme o kind

Dados de importação costumam chegar chaveados por nome de campo, então o primeiro trabalho é traduzir nomes em endereços de página e widget. O FieldIndex conta anotações de widget numa página na mesma ordem que FormFieldCount e FormFieldInfo, então um scan simples funciona. O helper recebe o primeiro widget que encontra para um nome, o que importa para campos compartilhados, como a próxima seção explica

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // só o primeiro widget: uma requisição por campo
      end;
  end;
end;

procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

Note que o AddRequest levanta num nome desconhecido em vez de pular. O lote valida endereços, não o seu mapeamento, então um campo de template ausente precisa falhar aqui ou nunca vai falhar. Com os endereços resolvidos, construir os valores e comitá-los é curto

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // valor de export do botão a selecionar
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // índice com base 0 no array Opt do campo
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
      Pdf.SaveAs('claim-filled.pdf')
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

Os kinds tipados são conferidos contra o tipo do campo antes de qualquer coisa ser gravada. Um valor de texto mirado num checkbox, um valor de radio mirado num checkbox simples, ou uma gravação num push button, todos rejeitam. Índices de choice precisam ser válidos, únicos e ascendentes; mais de um índice exige a flag MultiSelect, e texto livre num campo choice é aceito só por um combo box editável. Um valor de texto maior que o MaxLen do campo rejeita em vez de truncar, e campos read-only rejeitam de cara. Para grupos de radio com a flag NoToggleToOff, um valor de export vazio, significando "limpar o grupo", também rejeita

Por que gravar um campo compartilhado duas vezes falha o lote inteiro?

O TPdf.SetFormFieldValues rejeita duas requisições que resolvem para o mesmo campo terminal porque ambas gravariam um único valor, e o componente se recusa a deixar uma requisição sobrescrever silenciosamente outra. A checagem roda no dono do campo, não no widget, então dois widgets diferentes de um campo em duas páginas diferentes ainda contam como duplicado

O motivo é o modelo de campos da ISO 32000-1 §12.7.3.1. Um campo terminal é dono do valor; as anotações de widget dele são só lugares onde esse valor é exibido. Um campo chamado PolicyNumber que aparece no header de toda página é um campo com vários widgets, e cada um desses widgets mostra o mesmo /V. Um grupo de radio funciona igual: cada botão é um widget, o grupo é o campo. Gravar PolicyNumber pelo widget da página 1 e de novo pelo widget da página 9 é um campo gravado duas vezes, e qualquer regra que escolhe um vencedor esconde um bug no seu mapeamento

Então o lote trata isso como erro e nomeia a segunda requisição no FailedRequest. Na prática a correção é a montante: resolva cada nome de campo para exatamente um endereço, como o FindFieldAddress faz, e deixe o componente propagar o valor para os outros widgets. Ele faz isso como parte da gravação, definindo /AS em todo widget de um checkbox ou grupo de radio compartilhado para que o estado de aparência concorde com o valor do campo, a mesma divisão coberta em o artigo sobre valores de campo de checkbox versus estados de widget

Um campo terminal é dono do valor, widgets em duas páginas o exibem, e uma requisição duplicada falha o lote no PDFium Component para Delphi
uma requisição por campo terminal — o componente grava /V uma vez e define /AS em todo widget que o compartilha

Lendo o relatório de falha

Um lote falho devolve False, não levanta, e deixa o documento original e todo valor de campo exatamente como estavam. O TPdfFormValuesWriteReport então reporta Succeeded = False, zero tanto para UpdatedFields quanto para UpdatedWidgets, o índice com base 0 da requisição infratora no FailedRequest, e um motivo legível por humanos no ErrorMessage

procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

Um FailedRequest de -1 significa que a rejeição pertence à transação e não a uma requisição: foco nativo estava ativo, o documento está assinado, o form é XFA, ou uma checagem de permissão falhou. Um valor não negativo aponta para uma entrada. Para um estouro de orçamento agregado ele aponta para a requisição que empurrou o lote além do limite, que não é necessariamente a requisição errada. Mantenha um array paralelo de nomes de colunas de origem ao lado de Requests e você transforma o índice 14 direto em "linha 2087, coluna ShipDate" no log de importação

Em sucesso, UpdatedFields iguala a contagem de requisições e UpdatedWidgets conta todo widget tocado, compartilhados incluídos, uma checagem de sanidade barata contra o template

Pré-condições e limites que você vai esbarrar

A rejeição mais comum numa aplicação viewer é o foco nativo: se um usuário está digitando num campo, o SetFormFieldValues recusa antes mesmo de capturar o snapshot, e o buffer do editor pendente sobrevive intacto. O componente não vai derrubar o foco às escondidas, porque isso ou comitaria ou perderia texto que o usuário não terminou. Chame ClearFormFieldFocus deliberadamente, o que encerra a sessão do editor do jeito que clicar fora do campo faria, e então retente

Os orçamentos são fixos e documentados, então você pode dimensionar importações contra eles:

  • De 1 a 256 requisições por lote; divida registros maiores em vários lotes e aceite que a atomicidade então vale por lote
  • No máximo 262.144 unidades UTF-16 de texto e 4.096 índices de choice por requisição
  • No máximo 1.048.576 unidades UTF-16 de texto e 16.384 índices de choice no lote inteiro
  • Os orçamentos usuais de documento para o parser de snapshot continuam valendo, incluindo uma fonte de 64 MiB e 65.536 objetos

Uma substituição bem-sucedida também invalida handles nativos, buscas e seleções obtidos antes da chamada, porque o documento por baixo é novo. Ativação de página e view e cursores lógicos de campos são mantidos; qualquer outra coisa que você cacheou, como handles de anotação ou page-object, precisa ser buscada de novo

Como isso difere da importação de XFDF?

O TPdf.ImportXFDF e o TPdf.SetFormFieldValues ambos põem dados em campos, mas a importação XFDF é um merge tolerante enquanto o lote é uma transação estrita. A importação XFDF bate valores com campos por nome, pula nomes que não encontra, ignora duplicados, e devolve quantas entradas aplicou. Isso é exatamente certo para mesclar comentários de um revisor, onde um resultado parcial ainda é útil

O lote faz o oposto em cada ponto. Ele endereça widgets explicitamente, rejeita alvos desconhecidos ou tipados errado, trata duplicados como erros, e não tem resultado parcial. Use XFDF quando os dados são um arquivo de intercâmbio de outra ferramenta; use SetFormFieldValues quando o seu próprio código é dono do mapeamento e um registro meio aplicado seria um defeito

Matriz de decisão comparando a importação XFDF com o lote atômico SetFormFieldValues no PDFium Component para Delphi: endereçamento, alvos desconhecidos e duplicados, resultado e melhor uso
o XFDF mescla arquivos de intercâmbio com tolerância enquanto o lote recusa nomes desconhecidos, duplicados e resultados parciais

O que a gravação em lote não cobre

O lote é um perfil AcroForm delimitado, e ele diz não em vez de chutar fora desse perfil. Formulários XFA rejeitam e precisam do editor dedicado deles. Documentos assinados rejeitam, já que reescrever dicionários de campos sob uma assinatura existente é um fluxo de assinatura, não uma operação de preenchimento; snapshots criptografados também estão fora do perfil. Arrays de export de botões malformados ou ambíguos rejeitam, e valores de export de radio que batem com mais de um botão são recusados a menos que o grupo defina RadiosInUnison. A transação também normaliza dicionários de campos diretos antes de gravar, o caso por trás de valores de campo que aparentam reverter

Aparências merecem uma frase de cuidado. Para widgets de texto e choice alterados o lote derruba o /AP velho em vez de distribuir uma aparência que mostra o valor antigo, e desliga o NeedAppearances para que o PDFium não reconstrua todo widget não relacionado do formulário. O PDFium regenera as aparências ausentes quando renderiza. Se o PDF preenchido vai para um pipeline de impressão estrito que nunca sintetiza aparências, renderize ou achate-o via PDFium primeiro, e teste esse caminho com os seus próprios templates

O SetFormFieldValues e os records de valor tipados vêm no PDFium Component for Delphi and Lazarus, onde a página do produto lista a API completa de preenchimento de formulários e AcroForm junto com controles de renderização, edição e viewer