Artigo Técnico

Valores de campos PDF que revertem: campos diretos no Delphi

Um valor de campo de formulário PDF "reverte" quando o campo é guardado como dicionário direto: o PDFium promove a cópia no array /Annots da página a objeto indireto, enquanto o /Fields mantém uma segunda cópia direta. O PDFium Component for Delphi trata escritas de campos tipadas unificando ambas as referências num único dono indireto antes de escrever

O sintoma normalmente lê-se como um assombro. A escrita devolve True, e a leitura imediatamente seguinte entrega o valor antigo. Salve, reabra, e o valor antigo continua lá. Nada lançou, e o PDF parece perfeitamente comum em todos os viewers que possui

O sintoma: uma escrita bem-sucedida que não cola

O padrão de falha é estreito mas muito repetível: só aparece em formulários cujos dicionários de campo são escritos inline (como objetos diretos) em vez de objetos indiretos numerados. A maioria das ferramentas de autoria emite campos indiretos, por isso o problema esconde-se durante meses e depois aparece em ficheiros de um gerador específico, muitas vezes um exportador artesanal que escreve o /AcroForm inteiro inline

Duas observações juntas apontam para a causa: a escrita reporta sucesso, e uma leitura nativa do campo através da camada de formulário devolve o valor anterior. Quando vir ambos, pare de suspeitar da sua codificação de strings e comece a olhar para a posse de objetos

Porque é que um valor de campo de formulário PDF reverte após uma escrita bem-sucedida?

Um valor de campo de formulário PDF reverte porque dois dicionários separados reclamam ambos ser o mesmo campo, e a escrita atualizou a cópia que a árvore de campos não consulta. O valor de um campo vive na sua entrada /V, e o ISO 32000-1 §12.7.3.1 define onde essa entrada é autoritativa: no dicionário de campo alcançado a partir do /AcroForm /Fields, descendo pelo /Kids até ao campo terminal, com entradas herdáveis resolvidas a partir do /Parent

A mesma cláusula admite uma conveniência que causa todo o incómodo. Quando um campo tem exatamente uma anotação de widget, o dicionário de campo e o dicionário de anotação de widget podem ser fundidos num único dicionário. Num ficheiro bem formado esse dicionário fundido é um objeto indireto, referenciado de dois lugares: do /Fields (ou do /Kids de um pai) e do array /Annots da página. Duas referências, um objeto, um /V

Duas cópias de um dicionário

O ISO 32000-1 §7.3.10 é explícito em que só um objeto indireto tem identidade que pode ser partilhada. Um dicionário direto não tem número de objeto, por isso quando um gerador escreve o dicionário campo/widget fundido inline tanto no /Fields como no /Annots, o ficheiro não contém um objeto referenciado duas vezes. Contém dois dicionários de aparência igual que acontecem ter as mesmas chaves e valores

O PDFium tolera isto, e ao fazê-lo torna a divisão permanente. Quando precisa de um handle para a anotação, o PDFium pode promover o dicionário de widget direto no array /Annots da página a objeto indireto, mas a cópia direta equivalente fica onde estava dentro do /AcroForm /Fields (ou dentro do /Kids do pai). A partir desse momento o documento segura dois donos para um campo lógico. Um writer que edite o /V na anotação da página obtém um sucesso genuíno ao nível do dicionário. A árvore de campos, que é o que a camada de formulário e todos os leitores posteriores tratam como fonte de verdade, nunca vê a mudança

É por isso que o valor velho sobrevive a uma gravação e reabertura: o novo valor foi serializado fielmente num objeto que a hierarquia de campos não possui

PDFium Component em Delphi: dois dicionários diretos reclamam um campo, a escrita atinge a anotação promovida e a árvore de campos continua a ler a cópia /Fields antiga
a escrita serializou fielmente num objeto que a hierarquia de campos não possui, por isso o valor velho sobrevive a gravação e reabertura

Como o PDFium Component restaura um único dono de campo

O PDFium Component normaliza a posse direta de campos antes de cada escrita tipada: promove os dicionários /AcroForm e de campos diretos a objetos indiretos, liga cada widget direto à sua anotação de página correspondente, e reescreve /Fields, /Kids, /Parent e /Annots para que cada referência aponte para o mesmo objeto indireto. Só então muda o /V, por isso o valor do campo, o widget selecionado e o documento salvo partilham um único dono

A parte difícil é a identidade. Dois dicionários diretos com conteúdo idêntico não provam que são o mesmo widget; um formulário pode legitimamente conter dois widgets parecidos. O normalizador por isso recusa-se a adivinhar, e exige um nome de anotação /NM explícito e único (ISO 32000-1 §12.5.2) em cada widget direto. As regras aplicadas pelo caminho de escrita são estritas:

  • Um widget direto precisa de um /NM não vazio de no máximo 1024 unidades UTF-16, e exatamente uma anotação de página pode carregar o mesmo nome
  • A cópia direta e a anotação da página têm de serializar identicamente fora o /Parent e o /AP regenerado pelo PDFium; qualquer outra diferença rejeita a escrita
  • Ambas as ligações de pai são verificadas separadamente, por isso um /Parent correto na anotação da página não pode mascarar um conflitante na cópia direta original
  • Um /Parent em falta é fornecido só quando a posse única da árvore de campos torna a resposta inequívoca, e cópias de pais diretas equivalentes são reatadas ao seu dono promovido
  • Nomes de campo totalmente qualificados duplicados, árvores de campos cíclicas ou de posse múltipla e campos de assinatura rejeitam antes de qualquer coisa ser publicada
  • Anotações não relacionadas, como links na mesma página, e entradas /AcroForm não relacionadas são mantidas intocadas

O normalizador também corre sob orçamentos duros: 4096 visitas de árvore de campos, páginas ou membros de array, profundidade de aninhamento 64, 256 novos objetos indiretos, 1 MiB por dicionário promovido, 4 MiB de bytes promovidos no total, 128 MiB de bytes de dicionário inspecionados, e 4096 unidades UTF-16 por nome de campo completo com 262144 no formulário. Um ficheiro hostil ou partido atinge um orçamento e falha com uma mensagem de erro em vez de transformar um preenchimento de formulário numa negação de serviço

A operação inteira é transacional. O TPdf.SetFormFieldValues captura uma fotografia, constrói uma atualização incremental contendo os objetos normalizados e os novos valores, carrega esse candidato, e só substitui o documento vivo depois de o candidato verificar. Se a normalização rejeitar o ficheiro, ou um pedido posterior num batch falhar, o documento original, os seus valores e o seu cursor lógico de campos ficam exatamente como estavam

Como o PDFium Component para Delphi unifica a posse direta de campos AcroForm: fotografia, verificação de identidade NM, unificação de referências, escrita de valor e publicação verificada com caminhos de rejeição explícitos
dicionários de campos diretos são promovidos e reatados a um único dono indireto antes de o /V mudar, e posse ambígua rejeita de forma limpa

Escrever e verificar um valor de campo direto em Delphi

O padrão seguro é escrever, depois ler de volta através da camada nativa de formulário, e tratar uma divergência como falha mesmo quando a escrita devolveu True. O TPdf.SetFormFieldValue endereça um campo por índice na página atual, recebe um TPdfFormValue tipado e enche um TPdfFormValueWriteReport; o TPdf.FormField[Index] lê o valor de volta através da camada de formulário do PDFium, que resolve a árvore de campos em vez de confiar no widget

Padrão de escrita e verificação para campos de formulário PDF em Delphi com o PDFium Component: SetFormFieldValue tipado, leitura de volta pelo FormField, depois verificações de gravação e reabertura
um retorno True sozinho prova que o dicionário mudou — leia de volta pela árvore de campos e teste uma gravação e reabertura
uses
  System.SysUtils, PDFium;

procedure WriteTextField(Pdf: TPdf; PageNo, FieldIndex: Integer;
  const NewText: WString);
var
  Value: TPdfFormValue;
  Report: TPdfFormValueWriteReport;
begin
  Pdf.PageNumber := PageNo;          // SetFormFieldValue mira a página atual
  Value := Default(TPdfFormValue);
  Value.Kind := fvkText;
  Value.Text := NewText;

  if not Pdf.SetFormFieldValue(FieldIndex, Value, Report) then
    raise Exception.CreateFmt('Field %d on page %d rejected: %s',
      [FieldIndex, PageNo, Report.ErrorMessage]);

  // Verificar contra a árvore de campos, não contra a nossa própria variável
  if Pdf.FormField[FieldIndex] <> NewText then
    raise Exception.CreateFmt('Field "%s" did not keep the new value',
      [Pdf.FormFieldInfo[FieldIndex].Name]);
end;

Num ficheiro com posse irresolvível, por exemplo um widget direto sem /NM, o SetFormFieldValue devolve False, o Report.UpdatedWidgets é zero e o Report.ErrorMessage nomeia o problema, como "Direct widgets require an explicit unique NM identity". Esse é o comportamento que quer: uma rejeição clara em vez de uma meia-escrita silenciosa. Mais uma precondição que vale a pena conhecer. A escrita tipada é só de anotações e não adquire focus nativo, por isso se um editor de campo nativo estiver ainda com focus a transação inteira rejeita antes de a fotografia ser capturada; chame Pdf.ClearFormFieldFocus deliberadamente primeiro em vez de repetir em loop

Para vários campos, sobretudo entre páginas, use a forma em batch. O TPdf.SetFormFieldValues aceita 1 a 256 pedidos, aplica todos a um candidato, e publica tudo ou nada. O TPdfFormValuesWriteReport.FailedRequest diz-lhe que pedido partiu o batch

procedure FillInvoiceHeader(Pdf: TPdf);
var
  Requests: TPdfFormValueRequests;
  Batch: TPdfFormValuesWriteReport;
begin
  SetLength(Requests, 2);
  Requests[0] := Default(TPdfFormValueRequest);
  Requests[0].Page := 1;
  Requests[0].FieldIndex := 0;
  Requests[0].Value.Kind := fvkText;
  Requests[0].Value.Text := 'ACME Tooling Inc.';

  Requests[1] := Default(TPdfFormValueRequest);
  Requests[1].Page := 2;
  Requests[1].FieldIndex := 0;
  Requests[1].Value.Kind := fvkText;
  Requests[1].Value.Text := '2026-10-27';

  if not Pdf.SetFormFieldValues(Requests, Batch) then
    raise Exception.CreateFmt('Request %d failed: %s',
      [Batch.FailedRequest, Batch.ErrorMessage]);
  // Batch.UpdatedFields = 2; Batch.UpdatedWidgets conta cada widget sincronizado
end;

Dois pedidos a endereçar o mesmo campo terminal são rejeitados como dono duplicado em vez de deixar a última escrita ganhar, e o documento fonte é preservado. Valores de checkbox e rádio seguem as mesmas regras; a divisão /V contra /AS para botões está coberta em valor de campo de checkbox contra estado de widget no PDFium

Provar que o valor sobrevive a gravação e reabertura

Uma leitura de volta na mesma sessão prova que o documento vivo é coerente. Uma gravação e reabertura prova que o ficheiro é, e a verificação é barata o suficiente para manter nos seus próprios testes de integração

procedure SaveAndReopenCheck(Pdf: TPdf; const Target: string;
  PageNo, FieldIndex: Integer; const Expected: WString);
var
  Reopened: TPdf;
begin
  if not Pdf.SaveAs(Target, saNoIncremental, pvUnknown) then
    raise Exception.Create('Save failed');

  Reopened := TPdf.Create(nil);
  try
    Reopened.FileName := Target;
    Reopened.Active := True;
    Reopened.PageNumber := PageNo;
    if Reopened.FormField[FieldIndex] <> Expected then
      raise Exception.Create('Saved file lost the field value');
  finally
    Reopened.Free;
  end;
end;

Mais duas armadilhas ao nível do byte: Trim e o /ID do trailer

Normalizar campos diretos significa reler bytes crus de dicionário, o que traz duas armadilhas menores relevantes para qualquer código Delphi que toque em sintaxe PDF

A primeira é o SysUtils.Trim sobre bytes PDF crus guardados num AnsiString. Uma string literal como uma opção de choice pode legitimamente conter texto UTF-16BE com uma marca de ordem de bytes FE FF e bytes NUL embutidos. No Delphi Unicode, o SysUtils.Trim recebe um string, por isso a chamada converte silenciosamente o AnsiString para UTF-16 usando a página de código ANSI ativa e converte o resultado de volta. Consoante essa página de código, a ida e volta pode reescrever bytes altos, danificar a BOM e até perturbar o delimitador que se segue. A abordagem segura mantém o lexer nos bytes: salte whitespace PDF por posição (o ISO 32000-1 §7.2.2 lista NUL, tab, LF, FF, CR e espaço) e corte com Copy, descodificando só quando um valor se sabe ser uma string de texto PDF

// Arriscado em bytes PDF crus: AnsiString -> string -> AnsiString (W1057/W1058)
Token := Trim(Token);

// Seguro em bytes: saltar whitespace PDF por posição, depois cortar
P := 1;
while (P <= Length(Token)) and (Token[P] in [#0, #9, #10, #12, #13, ' ']) do
  Inc(P);
Token := Copy(Token, P, MaxInt);

A segunda armadilha assenta na própria atualização incremental. O ISO 32000-1 §7.5.5 (Tabela 15) e §14.4 exigem que o /ID do trailer seja um array de duas strings de bytes. O helper interno que compõe o identificador da atualização esparsa devolve só o conteúdo das duas strings, sem a chave /ID nem os parênteses retos do array, e o trailer tem de o envolver explicitamente como /ID [ ... ]. O loader tolerante do PDFium, e uma regravação completa posterior, ambos escondem um wrapper em falta, por isso a verificação fiável é analisar os ficheiros candidato e salvo com um parser estrito independente e procurar duas strings de 16 bytes. Se escrever atualizações incrementais você mesmo, a mesma armadilha aplica-se; o artigo de atualização incremental para PDFs encriptados cobre a cadeia do trailer com mais profundidade

O que deve fazer no seu próprio código de formulários Delphi?

  • Leia sempre o valor de volta com FormField[Index] após uma escrita, e nos testes salve, reabra e leia outra vez
  • Trate False mais ErrorMessage como informação: uma rejeição que nomeie NM, um conflito de pais ou um nome duplicado significa que a posse do ficheiro é genuinamente ambígua
  • Se gera PDFs, emita campos e widgets como objetos indiretos e dê a cada widget um /NM único; isso remove a ambiguidade na fonte
  • Use SetFormFieldValues para preenchimentos multi-campo ou entre páginas para que uma falha tardia não possa deixar metade do formulário atualizado
  • Nunca corra Trim ou outros helpers string da RTL sobre bytes PDF crus guardados em AnsiString

Seja claro quanto às fronteiras, também. Documentos assinados, formulários XFA, fotografias encriptadas e ficheiros cuja posse de widgets não pode ser resolvida de forma única ficam fora do perfil declarado e rejeitam em vez de serem forçados a passar. Se o seu fluxo move campos entre documentos, as questões de posse tornam-se ainda mais difíceis; enxertar campos AcroForm entre documentos PDF trata desse caso

O PDFium Component traz esta escrita de formulários tipada e transacional a aplicações Delphi, C++Builder e Lazarus ao lado de renderização, extração de texto e edição de documentos. Histórico de versões, licenciamento e downloads estão na página do produto PDFium Component for Delphi