Artigo Técnico

Valores de campo AcroForm herdados e resets no Delphi

O HotPDF Delphi Component trata /FT, /Ff, /V e /DV num campo AcroForm carregado como atributos herdáveis, resolvidos percorrendo a cadeia /Parent. Desde a v2.754.3 e a v2.754.4, um filho nomeado cujo tipo vem do pai continua endereçável individualmente, o RemoveFormField deixa os irmãos em paz, e o ResetLoadedFormField copia o default herdado com o tipo de objeto PDF original dele. Antes disso, uma quantidade surpreendente de formulários comuns estava sendo lida errado

O formulário que expõe tudo isso não é exótico. Uma ferramenta de autoria monta um nó de grupo group que carrega /FT /Ch, os field flags e a lista de opções uma vez só, e pendura nele dois filhos nomeados a e b, cada um um dicionário mesclado de campo mais widget sem nada além de /T, /Parent, /Rect e o /V próprio. Essa é uma forma perfeitamente legal de compartilhar atributos, e é exatamente o caso que a seção de limites de definir valores de campo de formulário num PDF carregado no Delphi marcava como não tratado: a reconciliação de botões olhava só o /FT local. Este artigo pega de onde aquele parou, cobrindo como a árvore de campos é classificada, como os valores herdados são lidos, e o que um reset de campo único tem permissão para escrever

Quais entradas AcroForm um campo pode herdar do pai dele?

A ISO 32000-1 §12.7.3.1, Tabela 220, marca /FT, /Ff, /V e /DV como herdáveis, e a Tabela 229 em §12.7.4.3 faz o mesmo para o /MaxLen de um campo de texto, então qualquer leitor que olhar só o dicionário local vai reportar tipo errado, flags erradas e valor vazio para um filho perfeitamente válido. O HotPDF canaliza todas essas leituras por um resolver interno único, o HPDFLoadedInheritedFieldObject, que confere a chave no dicionário, resolve uma referência indireta se achar uma, e caso contrário segue o /Parent por no máximo 128 níveis, porque arquivos malformados podem montar ciclos de /Parent que nada têm a ver com /Kids. Os getters públicos ficam em cima dele: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue e os helpers de opções GetLoadedFormFieldOptionCount e GetLoadedFormFieldOptions, que também pegam um array /Opt guardado no pai. Uma regra do resolver é fácil de errar: a caminhada para no primeiro dicionário que contém a chave, mesmo que o valor lá seja uma string vazia. Um /V () local é uma sobreposição deliberada que mascara o pai, não uma lacuna a preencher lá de cima na árvore

Diagrama de atributos AcroForm herdados do HotPDF: um nó de grupo carrega /FT, /Ff e /Opt uma vez só enquanto os filhos nomeados group.a e group.b guardam apenas /T, /Parent, /Rect e um /V local, mostrando o HPDFLoadedInheritedFieldObject percorrendo /Parent por até 128 níveis, em que o primeiro dicionário com a chave vence e um valor local vazio mascara o pai
O HotPDF resolve /FT, /Ff, /V, /DV e /Opt por um único resolver que sobe pelos pais, então um filho nomeado continua endereçável enquanto um valor local vazio deliberadamente sobrepõe tudo que o grupo acima dele carrega
var
  Pdf: THotPDF;
  Field: THPDFLoadedFormField;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    // 'group' carrega /FT /Ch, /Ff 131078 e /Opt; o filho
    // 'group.b' carrega só /T, /Parent, /Rect e o /V dele
    Field := Pdf.GetFormField('group.b');
    try
      if Pdf.GetFormFieldType(Field.Index) = lfftChoice then
      begin
        // 131078 = Combo (bit 18) + NoExport (bit 3) + Required (bit 2)
        Writeln(Pdf.GetLoadedFormFieldFlags(Field.Index));
        Writeln(Pdf.IsFormFieldRequired(Field.Index));    // TRUE
        Writeln(Pdf.GetLoadedFormFieldOptionCount(Field.Index));
        Writeln(Pdf.GetFormFieldValue(Field.Index));       // o /V local
      end;
    finally
      Field.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Por que um /FT local é o teste errado para um campo terminal?

Porque um pai pode fornecer o tipo e ainda assim ter campos filhos nomeados, então a presença de /FT não diz nada sobre onde a árvore de campos termina. A travessia antiga declarava um nó terminal sempre que ele tinha /FT próprio ou não tinha /Kids. No formulário acima, o group tem tanto /FT /Ch quanto /Kids, então era registrado como um campo único chamado group com dois widgets, e os nomes totalmente qualificados group.a e group.b simplesmente sumiam. O GetFormFieldCount retornava 1, uma busca por nome de filho falhava, e o SetFormFieldValue só conseguia escrever no pai compartilhado. O teste substituto, HPDFLoadedFieldHasChildFields, olha os kids em vez do pai: um kid é um campo filho se tem /T próprio, tem /Kids próprio, ou não é um dicionário /Subtype /Widget de forma nenhuma. Só quando nenhum kid se qualifica é que o nó é terminal, com os kids dele tratados como as anotações de widget dele

Os dois casos de borda que moldaram essa regra vêm ambos de dicionários mesclados, que a §12.7.3.1 permite quando um campo tem um único widget. Um dicionário mesclado nomeado carrega /Subtype /Widget e ainda assim é um campo filho, então o subtype sozinho não pode mandá-lo para a lista anônima de widgets do pai; o /T vence. O inverso também acontece: alguns produtores repetem o /FT do pai em todo widget anônimo, então o /FT não pode servir de evidência de que um widget inicia um campo novo. A classificação é compartilhada pelo cache de relacionamentos, pelo FormFieldExists e pelo RemoveFormField, e cada uma dessas caminhadas agora registra os dicionários que já visitou e para depois de 128 níveis. Um arquivo de regressão cujo grupo se lista duas vezes, /Kids [5 0 R 5 0 R 6 0 R 7 0 R], ainda reporta exatamente dois campos em vez de recursar para sempre ou contar o mesmo nó duas vezes

Como o RemoveFormField evita apagar campos irmãos?

O RemoveFormField agora apaga só o filho que você nomeia, porque descoberta e remoção finalmente concordam sobre o que é um campo terminal. Esse acordo importa mais do que parece. A overload por nome resolve um índice pelo cache de relacionamentos e depois conta os campos terminais numa segunda caminhada sobre /AcroForm /Fields. Depois que o cache foi corrigido para enxergar group.a e group.b, uma caminhada de remoção sem correção ainda teria tratado o group como um único campo terminal, e o índice 0 teria removido o pai junto com todo irmão e todos os widgets deles. A caminhada de remoção agora usa o mesmo teste HPDFLoadedFieldHasChildFields e o mesmo conjunto de visitados, coleta as anotações de widget só do filho removido, tira essas de cada /Annots de página, e remove o pai só quando o array /Kids dele acabar vazio. A regressão confere todos os três lugares em que um erro apareceria: o /Kids do pai, o /Annots da página, e o valor e a aparência do irmão sobrevivente, tanto depois de uma reescrita completa quanto depois de um update incremental

Diagrama de sobrevivência de irmãos no RemoveFormField do HotPDF: a caminhada de remoção reutiliza o HPDFLoadedFieldHasChildFields e o conjunto de visitados da descoberta, tira só o filho nomeado group.a do AcroForm /Fields e do /Annots da página, e mantém o pai compartilhado enquanto o array /Kids dele ainda guarda o group.b sobrevivente
Descoberta e remoção finalmente concordam sobre o que é um campo terminal, então remover um filho nomeado deixa o valor e a aparência do irmão intactos depois de uma reescrita completa ou de um update incremental
// Remove um filho nomeado; o irmão dele e o pai compartilhado sobrevivem
Pdf.RemoveFormField('group.a');

Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Tipo, flags e opções continuam resolvidos pelo pai
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');

O que o ResetLoadedFormField escreve quando o default é herdado?

O ResetLoadedFormField escreve um /V local que é uma cópia fresca do /DV herdado com o mesmo tipo de objeto PDF, e valida o default inteiro antes de tocar no campo. O tipo de objeto importa porque os getters escalares achatam tudo para texto. O default de um checkbox é um name como /Yes, o default de um list box multi-select é um array de strings, e o default de um campo de texto pode ser uma string UTF-16 hexadecimal; copiar qualquer um deles via GetLoadedFormFieldDefaultValue transformaria o name em string, o array em string vazia e a string hex em seus dígitos literais. O reset então ramifica pelo tipo herdado: campos de texto e choice ganham um objeto string novo que mantém a flag IsHexadecimal, campos choice com default em array ganham um array novo de strings novas, e botões que não são pushbutton ganham um objeto name novo. Copiar, em vez de apontar para os objetos do pai, é deliberado: um /V que compartilhasse o array /DV do pai ou o número de objeto dele mudaria o default na próxima vez que alguém editasse o valor. Um default de tipo errado, ou um array de choice contendo qualquer coisa que não strings, levanta exceção e deixa /V e /I exatamente como estavam. Pushbuttons, que não têm valor (Tabela 226, bit 17), e campos de assinatura caem no caminho antigo só de strings

Diagrama do reset tipado do HotPDF: o ResetLoadedFormField ramifica pelo tipo de objeto do /DV herdado, escrevendo um objeto name fresco para um checkbox, um array novo de strings novas para um choice multi-select, uma string que mantém IsHexadecimal para texto hex, uma string vazia ou /Off quando não existe /DV, e levantando exceção sem tocar em /V ou /I num erro de tipo
Copiar em vez de apontar para os objetos do pai impede que uma edição de valor posterior mude silenciosamente o default, e pushbuttons mais campos de assinatura caem no caminho antigo só de strings

Quando não existe /DV em lugar nenhum subindo pela cadeia, o método mantém o contrato de limpeza dele escrevendo uma string vazia local, ou /Off para campo checkbox ou radio. Apagar o /V local pareceria mais elegante e estaria errado: o pai pode ter um valor corrente, e remover a sobreposição do filho traria esse valor de volta em silêncio. É também por isso que um reset de campo único não é a action ResetForm da §12.7.5.3, que um viewer roda sobre um conjunto de campos quando o usuário clica um botão, como descrito em construir campos e actions AcroForm com o HotPDF. O ResetLoadedFormField é uma operação de edição num único campo carregado, com regra própria para o caso sem default, e registra o campo via NoteLoadedFormFieldDirty para o recálculo incremental enxergar a mudança

var
  Field: THPDFLoadedFormField;
begin
  Field := Pdf.GetFormField('group.a');
  try
    // O pai guarda /DV [(b) (r)] num list box MultiSelect: group.a ganha
    // o /V [(b) (r)] próprio e um /I [0 2] fresco; o pai fica intacto
    Pdf.ResetLoadedFormField(Field.Index);
    // Getters escalares não conseguem representar o default em array
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vazio
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Mantendo /V, /I e /AS em acordo

Um reset só está correto se o índice de seleção e o estado de aparência seguirem o valor, então o ResetLoadedFormField termina com os mesmos dois reconcilers do SetFormFieldValue. O HPDFReconcileChoiceSelection agora aceita um valor em array: ele apaga o /I local sem mutá-lo, casa cada valor contra a metade de export de cada entrada /Opt, e escreve um /I novo e ordenado, então um reset para [(b) (r)] contra opções b, g, r produz /I [0 2]. O ReconcileLoadedButtonAppearanceStates agora pede o tipo herdado, então um checkbox filho cujo /FT /Btn mora no pai finalmente tem o /AS dele definido. No lado da escrita, o SetFormFieldValue e o SetLoadedFormFieldDefaultValue guardam um objeto name para um botão herdado que não é pushbutton mesmo quando o filho não tem entrada local de onde copiar o tipo. E quando o EnsureLoadedFieldAppearanceStream reconstrói aparências de botões, ele escreve /AS /Off a menos que o valor case com o estado on, e dá a cada state stream um /Type /XObject, /Subtype /Form e /BBox adequados; antes da v2.754.4, regenerar a aparência depois de um reset podia marcar o checkbox de novo antes de o arquivo ser salvo

Limites que valem conhecer antes de construir sobre isso

Os getters escalares continuam escalares. O GetFormFieldValue e o GetLoadedFormFieldDefaultValue retornam string vazia para um valor em array, convertem números e booleans para 42 ou true, e reportam uma string codificada em hex na grafia hexadecimal dela. Um ciclo de /Parent encerra a caminhada sem exceção, então um campo cujo tipo se perdeu num ciclo reporta lfftUnknown e flags 0 em vez de falhar. O SetFormFieldValue e o ResetLoadedFormField sempre escrevem o filho que você endereça e nunca promovem um valor ao pai compartilhado, o que está certo para filhos independentes, mas significa que grupos de radio devem ser endereçados pelo campo que detém a seleção. E cada chamada efetiva um campo por vez; nada aqui torna um lote de resets transacional

A resolução de atributos herdados, a classificação unificada da árvore de campos e o reset tipado descritos aqui fazem parte da API de formulários carregados do HotPDF Delphi Component para Delphi e C++Builder, junto com a criação de campos coberta em adicionar campos AcroForm a um PDF carregado no Delphi