Artigo Técnico

Formulários PDF em batch atómico em Delphi com PDFium

O PDFium Component for Delphi preenche um AcroForm atomicamente com TPdf.SetFormFieldValues: passa 1 a 256 pedidos tipados endereçados por página e widget, e o componente ou aplica todos os valores numa única substituição de documento ou não aplica nenhum. Quando um pedido é inválido, o TPdfFormValuesWriteReport.FailedRequest nomeia o pedido que afundou o batch

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 a um template pré-impresso. Corra esses campo a campo e o primeiro valor mau, um código que não é um export de rádio válido, uma string maior que o MaxLen do campo, deixa-o com um PDF que é meio dados velhos e meio novos. Nada crashou, por isso nada alertou ninguém. A API em batch existe para que uma falha seja uma recusa limpa em vez de um documento meio escrito

Porque é que o preenchimento campo a campo é perigoso para importação de dados?

O preenchimento campo a campo é perigoso porque cada escrita comete por si, por isso um erro a meio do loop deixa um documento que não casa nem com o registo fonte nem com o template original. O padrão clássico é um loop sobre atribuições FormField[Index], cada uma passando pelo ambiente nativo de preenchimento de formulários do PDFium um widget de cada vez. Os valores 1 a 17 aterram, o valor 18 levanta, e o chamador agora tem de saber exatamente que valores entraram para os desfazer

A maioria dos chamadores nunca escreve esse código de desfazer, e os que o escrevem normalmente erram por detalhe: um campo partilhado já mudou em cada página onde aparece, e estados de checkboxes já viraram. Se a gravação corre num bloco finally, o ficheiro meio preenchido vai para o disco com aparência de completo. Num trabalho de overprint isso é pior que nenhum ficheiro, porque imprime e alguém assina

O outro problema silencioso é o focus. O setter clássico conduz o editor interativo do PDFium: mata qualquer focus existente, faz focus no widget alvo, seleciona todo o texto, substitui-o e mata o focus outra vez. Isso serve para um trabalho headless, mas num viewer também acaba com a sessão de edição que o utilizador tinha aberta noutro campo. O caminho de escrita tipado que chegou no PDFium Component 3.231.0 não passa de todo pelo focus nativo de campos, e o 3.233.0 estendeu-o de campos únicos a batches entre páginas

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

O TPdf.SetFormFieldValues prepara cada pedido contra uma única fotografia capturada do documento, escreve todos os dicionários de campo numa passada, e troca o resultado acabado com uma única substituição. O documento original continua vivo até ao último passo, por isso qualquer falha antes desse passo o deixa intocado

A sequência de cinco etapas do preenchimento atómico em batch no PDFium Component para Delphi, do preflight à verificação e troca com resultados tudo-ou-nada
cada pedido é preparado contra uma única fotografia e o documento vivo só é tocado na troca final verificada

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

  1. As verificações de preflight correm primeiro: o documento tem de estar ativo, a contagem de pedidos tem de ser 1..256, nenhuma transação de page-object ou de edit-journal pode estar aberta, nenhum campo nativo pode segurar focus, e o documento tem de conceder permissão fill-forms ou modify
  2. Cada pedido é validado quanto ao intervalo de páginas e ao orçamento de entrada, e o FailedRequest acompanha o índice em verificação
  3. O componente captura uma fotografia de edição, normaliza o AcroForm nessa cópia, e aplica cada pedido pela ordem, escrevendo /V, /I para seleções de choice e /AS para cada widget que partilha o campo
  4. As mudanças são acrescentadas à fotografia como uma única atualização incremental esparsa no sentido do ISO 32000-1 §7.5.6, por isso objetos não relacionados são transportados byte a byte
  5. Uma instância verificadora separada carrega primeiro os bytes candidatos; só depois de abrir limpinho é que o componente descarrega o documento vivo e carrega o novo, recaindo na fotografia original se essa carga falhar

O passo interno de substituição é a mesma maquinaria que o componente usa para as suas fotografias de edit-journal, e é por isso que o journal de undo e recuperação de crashes e uma escrita em batch não se podem 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, por isso um script de abertura de documento não consegue reescrever campos nem agarrar focus a meio da substituição

Construir um batch de pedidos tipado em Delphi

Um batch é um array dinâmico de records TPdfFormValueRequest, cada um trazendo um Page base 1, um FieldIndex local à página base 0 e um TPdfFormValue tipado. O record de valor traz um Kind de fvkText, fvkChoice, fvkCheckBox ou fvkRadioButton, mais membros Text, ChoiceIndices e Checked usados conforme esse kind

Os dados de importação costumam chegar chaveados por nome de campo, por isso o primeiro trabalho é traduzir nomes em endereços de página e widget. O FieldIndex conta anotações de widget numa página pela mesma ordem que o FormFieldCount e o FormFieldInfo, por isso uma varredura simples funciona. O helper recebe o primeiro widget que encontra para um nome, o que importa para campos partilhados, como a secção seguinte 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: um pedido 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 o saltar. O batch valida endereços, não o seu mapeamento, por isso um campo de template em falta tem de falhar aqui ou nunca falha de todo. Com os endereços resolvidos, construir os valores e cometê-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 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 verificados contra o tipo do campo antes de qualquer coisa ser escrita. Um valor de texto dirigido a uma checkbox, um valor de rádio dirigido a uma checkbox simples, ou uma escrita num push button todos rejeitam. Índices de choice têm de ser válidos, únicos e ascendentes; mais de um índice exige a flag MultiSelect, e texto livre num campo de choice é aceite só por uma combo box editável. Um valor de texto maior que o MaxLen do campo rejeita em vez de ser truncado, e campos read-only rejeitam de plano. Para grupos de rádio com a flag NoToggleToOff, um valor de export vazio, significando "limpar o grupo", também rejeita

Porque é que escrever um campo partilhado duas vezes falha o batch inteiro?

O TPdf.SetFormFieldValues rejeita dois pedidos que resolvam ao mesmo campo terminal porque ambos escreveriam um único valor, e o componente recusa deixar um pedido sobrescrever silenciosamente outro. A verificação corre no dono do campo, não no widget, por isso dois widgets diferentes de um campo em duas páginas diferentes continuam a contar como duplicado

A razão é o modelo de campo do ISO 32000-1 §12.7.3.1. Um campo terminal possui o valor; as suas anotações de widget são só lugares onde esse valor é mostrado. Um campo chamado PolicyNumber que aparece no cabeçalho de cada página é um campo com vários widgets, e cada um desses widgets mostra o mesmo /V. Um grupo de rádio funciona da mesma forma: cada botão é um widget, o grupo é o campo. Escrever o PolicyNumber através do widget da página 1 e outra vez através do widget da página 9 é um campo escrito duas vezes, e qualquer regra que escolha um vencedor esconde um bug no seu mapeamento

Por isso o batch trata-o como erro e nomeia o segundo pedido em FailedRequest. Na prática a correção está a montante: resolva cada nome de campo a exatamente um endereço, como o FindFieldAddress faz, e deixe o componente propagar o valor aos outros widgets. Faz isso como parte da escrita, pondo /AS em cada widget de uma checkbox partilhada ou grupo de rádio 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 contra estados de widget

Um campo terminal possui o valor, widgets em duas páginas mostram-no, e um pedido duplicado falha o batch no PDFium Component para Delphi
um pedido por campo terminal — o componente escreve /V uma vez e põe /AS em cada widget que o partilha

Ler o relatório de falha

Um batch falhado devolve False, não levanta, e deixa o documento original e todos os valores de campo exatamente como estavam. O TPdfFormValuesWriteReport depois reporta Succeeded = False, zero tanto para UpdatedFields como para UpdatedWidgets, o índice base 0 do pedido infrator em FailedRequest, e uma razão legível por humanos em 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 um pedido: focus nativo estava ativo, o documento está assinado, o formulário é XFA, ou uma verificação de permissão falhou. Um valor não negativo aponta para uma entrada. Para um excesso de orçamento agregado aponta para o pedido que empurrou o batch acima do limite, que não é necessariamente o pedido que está errado. Mantenha um array paralelo de nomes de colunas fonte ao lado do Requests e consegue transformar o índice 14 diretamente em "linha 2087, coluna ShipDate" no log de importação

Em sucesso, o UpdatedFields é igual à contagem de pedidos e o UpdatedWidgets conta cada widget tocado, os partilhados incluídos, uma verificação sanitária barata contra o template

Precondições e limites que vai encontrar

A rejeição mais comum numa aplicação viewer é o focus nativo: se um utilizador está a digitar num campo, o SetFormFieldValues recusa antes mesmo de capturar a fotografia, e o buffer do editor pendente sobrevive intacto. O componente não larga o focus às suas costas, porque isso ou cometeria ou perderia texto que o utilizador não acabou. Chame ClearFormFieldFocus deliberadamente, o que termina a sessão de editor à maneira de um clique fora do campo, e depois repita

Os orçamentos são fixos e documentados, por isso pode dimensionar importações contra eles:

  • 1 a 256 pedidos por batch; divida registos maiores em vários batches e aceite que a atomicidade então vale por batch
  • No máximo 262.144 unidades UTF-16 de texto e 4.096 índices de choice por pedido
  • No máximo 1.048.576 unidades UTF-16 de texto e 16.384 índices de choice em todo o batch
  • Os orçamentos habituais de documento para o parser de fotografias continuam a valer, incluindo uma fonte de 64 MiB e 65.536 objetos

Uma substituição bem-sucedida também invalida handles nativos, pesquisas e seleções obtidos antes da chamada, porque o documento por baixo é novo. Ativação de páginas e vistas e cursores lógicos de campos são mantidos; tudo o resto que tenha em cache, como handles de anotações ou page-objects, tem de ser buscado outra vez

Em que difere isto do import XFDF?

O TPdf.ImportXFDF e o TPdf.SetFormFieldValues ambos metem dados em campos, mas o import XFDF é uma fusão tolerante enquanto o batch é uma transação estrita. O import XFDF casa valores com campos por nome, salta nomes que não encontra, ignora duplicados, e devolve quantas entradas aplicou. É exatamente o certo para fundir comentários de um revisor, onde um resultado parcial continua útil

O batch faz o oposto em todos os pontos. Endereça widgets explicitamente, rejeita alvos desconhecidos ou mal tipados, trata duplicados como erros, e não tem resultado parcial. Use XFDF quando os dados são um ficheiro de intercâmbio de outra ferramenta; use SetFormFieldValues quando o seu próprio código possui o mapeamento e um registo meio aplicado seria um defeito

Matriz de decisão a comparar o import XFDF com o batch atómico SetFormFieldValues no PDFium Component para Delphi: endereçamento, alvos desconhecidos e duplicados, resultado e melhor encaixe
o XFDF funde ficheiros de intercâmbio com tolerância enquanto o batch recusa nomes desconhecidos, duplicados e resultados parciais

O que a escrita em batch não cobre

O batch é um perfil AcroForm delimitado, e diz não em vez de adivinhar fora desse perfil. Formulários XFA rejeitam e precisam do seu editor dedicado. Documentos assinados rejeitam, já que reescrever dicionários de campo sob uma assinatura existente é um fluxo de assinaturas, não uma operação de preenchimento; fotografias encriptadas estão fora do perfil também. Arrays de export de botões malformados ou ambíguos rejeitam, e valores de export de rádio que casem com mais de um botão são recusados a menos que o grupo ponha RadiosInUnison. A transação também normaliza dicionários de campo diretos antes de escrever, o caso por trás de valores de campo que parecem reverter

As aparências merecem uma frase de cuidado. Para widgets de texto e choice alterados o batch larga 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 todos os widgets não relacionados do formulário. O PDFium regenera as aparências em falta quando renderiza. Se o PDF preenchido vai para um pipeline de impressão estrito que nunca sintetiza aparências, renderize-o ou achate-o através do PDFium primeiro, e teste esse caminho com os seus próprios templates

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