Artigo Técnico

Campos diretos e valores de formulário PDF que revertem

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

O sintoma normalmente lê como um assombro. A gravação devolve True, e a leitura seguinte devolve o valor antigo. Salve, reabra, e o valor antigo continua lá. Nada lançou, e o PDF parece perfeitamente comum em todo viewer que você tem

O sintoma: uma gravação bem-sucedida que não gruda

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

Duas observações juntas apontam para a causa: a gravação reporta sucesso, e uma leitura nativa do campo pela camada de formulário devolve o valor anterior. Quando você vê as duas, pare de suspeitar da sua codificação de strings e comece a olhar a posse de objetos

Por que um valor de campo de formulário PDF reverte após uma gravação bem-sucedida?

Um valor de campo de formulário PDF reverte porque dois dicionários separados ambos alegam ser o mesmo campo, e a gravação atualizou a cópia que a árvore de campos não consulta. O valor de um campo vive na entrada /V dele, e a 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é o campo terminal, com entradas herdáveis resolvidas do /Parent

A mesma cláusula permite uma conveniência que causa todo o problema. 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 arquivo 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

A ISO 32000-1 §7.3.10 é explícita que só um objeto indireto tem uma identidade que pode ser compartilhada. Um dicionário direto não tem número de objeto, então quando um gerador grava o dicionário campo/widget fundido inline tanto no /Fields quanto no /Annots, o arquivo não contém um objeto referenciado duas vezes. Ele contém dois dicionários de aparência igual que por acaso têm as mesmas chaves e valores

O PDFium tolera isso, 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 um objeto indireto, mas a cópia direta equivalente fica onde estava dentro do /AcroForm /Fields (ou dentro do /Kids do pai). Daquele momento o documento tem dois donos para um campo lógico. Um writer que edita o /V na anotação da página obtém um sucesso genuíno no nível do dicionário. A árvore de campos, que é o que a camada de formulário e todo leitor posterior tratam como fonte da verdade, nunca vê a mudança

É por isso que o valor velho sobrevive a um save e reopen: o valor novo foi serializado fielmente num objeto que a hierarquia de campos não possui

PDFium Component no Delphi: dois dicionários diretos alegam um campo, a gravação atinge a anotação promovida e a árvore de campos continua lendo a cópia /Fields antiga
a gravação serializou fielmente num objeto que a hierarquia de campos não possui, então o valor velho sobrevive a save e reopen

Como o PDFium Component restaura um único dono de campo

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

A parte difícil é 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 então se recusa a chutar, 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 gravação 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 de página precisam serializar identicamente exceto por /Parent e o /AP regenerado pelo PDFium; qualquer outra diferença rejeita a gravação
  • Os dois links de pai são conferidos separadamente, então um /Parent correto na anotação de página não pode mascarar um conflitante na cópia direta original
  • Um /Parent ausente é fornecido só quando a posse única na árvore de campos torna a resposta inequívoca, e cópias diretas de pai equivalentes são religadas ao dono promovido delas
  • Nomes de campos 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 roda sob orçamentos rígidos: 4096 visitas da á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 completo de campo com 262144 pelo formulário. Um arquivo hostil ou quebrado esbarra num 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 um snapshot, constrói uma atualização incremental contendo os objetos normalizados e os novos valores, carrega esse candidato, e só substitui o documento vivo uma vez que o candidato verifica. Se a normalização rejeita o arquivo, ou uma requisição posterior num lote falha, o documento original, os valores dele e o cursor lógico de campos ficam exatamente como estavam

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

Gravando e verificando um valor de campo direto no Delphi

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

Padrão de gravar e verificar para campos de formulário PDF no Delphi com PDFium Component: SetFormFieldValue tipado, leitura de volta pelo FormField, depois save e reopen como checagens
um retorno True sozinho prova que o dicionário mudou — releia pela árvore de campos e teste um save e reopen
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]);

  // Verifique 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 arquivo 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 você quer: uma rejeição clara em vez de uma meia gravação silenciosa. Mais uma pré-condição vale conhecer. A gravação tipada é apenas de anotação e não adquire foco nativo, então se um editor nativo de campo ainda está focado a transação inteira rejeita antes de o snapshot ser capturado; chame Pdf.ClearFormFieldFocus deliberadamente primeiro em vez de retentar num loop

Para vários campos, especialmente entre páginas, use a forma de lote. O TPdf.SetFormFieldValues aceita de 1 a 256 requisições, aplica todas a um candidato, e publica tudo ou nada. O TPdfFormValuesWriteReport.FailedRequest diz qual requisição quebrou o lote

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 todo widget sincronizado
end;

Duas requisições endereçando o mesmo campo terminal são rejeitadas como dono duplicado em vez de deixar a última gravação vencer, e o documento de origem é preservado. Valores de checkbox e radio seguem as mesmas regras; a divisão /V versus /AS para botões está coberta em valor de campo de checkbox versus estado de widget no PDFium

Provando que o valor sobrevive a save e reopen

Uma releitura na mesma sessão prova que o documento vivo é coerente. Um save e reopen prova que o arquivo é, e a checagem é barata o suficiente para manter nos seus 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 de nível de byte: Trim e o /ID do trailer

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

A primeira é o SysUtils.Trim sobre bytes PDF brutos 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, então a chamada converte silenciosamente o AnsiString para UTF-16 usando a code page ANSI ativa e converte o resultado de volta. Dependendo dessa code page, a ida e volta pode reescrever bytes altos, danificar o BOM e até perturbar o delimitador que segue. A abordagem segura mantém o lexer em bytes: pule whitespace PDF por posição (a ISO 32000-1 §7.2.2 lista NUL, tab, LF, FF, CR e espaço) e corte com Copy, decodificando só quando um valor for sabidamente uma string de texto PDF

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

// Seguro em bytes: pule whitespace PDF por posição, depois corte
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 fica na própria atualização incremental. A ISO 32000-1 §7.5.5 (Tabela 15) e a §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 colchetes do array, e o trailer precisa envolvê-lo explicitamente como /ID [ ... ]. O loader tolerante do PDFium, e um resave completo posterior, ambos escondem um wrapper ausente, então a checagem confiável é parsear os arquivos candidato e salvo com um parser estrito independente e procurar duas strings de 16 bytes. Se você grava atualizações incrementais por conta própria, a mesma armadilha se aplica; o artigo de atualização incremental para PDFs criptografados cobre a cadeia de trailers em mais profundidade

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

  • Sempre releia o valor com FormField[Index] depois de uma gravação, e em testes também salve, reabra e leia de novo
  • Trate False mais ErrorMessage como informação: uma rejeição nomeando NM, um conflito de pai ou um nome duplicado significa que a posse do arquivo é genuinamente ambígua
  • Se você gera PDFs, emita campos e widgets como objetos indiretos e dê a todo widget um /NM único; isso remove a ambiguidade na fonte
  • Use SetFormFieldValues para preenchimentos de vários campos ou entre páginas para que uma falha tardia não possa deixar metade do formulário atualizado
  • Nunca rode Trim ou outros helpers RTL de string sobre bytes PDF brutos guardados em AnsiString

Seja claro sobre as fronteiras também. Documentos assinados, formulários XFA, snapshots criptografados e arquivos 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 perguntas de posse ficam ainda mais difíceis; enxertar campos AcroForm entre documentos PDF trata desse caso

O PDFium Component traz essa escrita de formulários tipada e transacional para aplicações Delphi, C++Builder e Lazarus junto com 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