Artigo Técnico

Valores de campo AcroForm herdados e resets no Delphi

Um formulário de pesquisa mostra duas listas drop-down no Acrobat, group.a e group.b. Percorra /AcroForm /Fields com código que trata qualquer nó com /FT próprio como um campo completo, e a contagem volta como 1: um único campo chamado group com dois widgets. Pergunte por group.b pelo nome e a busca falha. Abra o dicionário do filho e não há /FT nenhum nele, nem /Ff, nem lista de opções. Nada está quebrado. O tipo, os flags e as opções ficam um nível acima, no pai, e a especificação PDF permite explicitamente que vivam lá

O layout é legal e econômico. Uma ferramenta de autoria grava um nó de grupo com /FT /Ch, /Ff 131078 e um array /Opt uma vez, e depois pendura dois filhos nomeados embaixo, cada um um dicionário mesclado de campo-plus-widget contendo apenas /T, /Parent, /Rect e seu próprio /V. Ler tal formulário corretamente exige três coisas: seguir /Parent para toda chave herdável, decidir onde a árvore de campos realmente termina, e garantir que um reset escreva um valor do tipo PDF certo. Este texto retoma de onde definir valores de campos de formulário em um PDF carregado com Delphi para

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 na §12.7.4.3 faz o mesmo para o /MaxLen de um campo de texto. Arrays de opções recebem o mesmo tratamento aqui, porque produtores que compartilham uma lista de escolhas entre filhos armazenam /Opt no grupo. Um leitor que só verifica o dicionário local vai, portanto, reportar o tipo errado, flags 0 e um valor vazio para um filho perfeitamente válido

O HotPDF Delphi Component responde cada uma dessas leituras da mesma forma. Ele procura a chave no dicionário do próprio campo e, se a chave está lá, resolve a referência indireta e para. Caso contrário, segue /Parent e tenta de novo, por no máximo 128 níveis. Esse teto não é enfeite. Ponteiros /Parent em arquivos danificados podem formar um loop que não tem nada a ver com /Kids, e um leitor que confia neles incondicionalmente trava no primeiro arquivo desses que encontrar

Uma regra desse caminho é fácil de entender ao contrário: o primeiro dicionário que contém a chave vence, mesmo quando o valor armazenado lá é uma string vazia. Um /V () local no group.b é o autor dizendo "este filho está em branco", e o caminho para ali em vez de subir para um valor que o pai possa ter. Tratar vazio como "ausente" faria um filho limpo exibir a seleção do pai, o oposto do que o autor escreveu

Diagrama de atributos AcroForm herdados do HotPDF: um nó de grupo carrega /FT, /Ff e /Opt uma vez enquanto os filhos nomeados group.a e group.b guardam apenas /T, /Parent, /Rect e um /V local, mostrando a busca de atributos herdados 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

Os getters públicos todos se apoiam nessa busca: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue, e os auxiliares de opções GetLoadedFormFieldOptionCount e GetLoadedFormFieldOptions. Ler o filho do grupo da pesquisa não precisa de código especial:

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;

O GetFormField retorna um novo wrapper THPDFLoadedFormField a cada chamada e levanta uma exceção quando o nome é desconhecido: mantenha a chamada fora do bloco try do wrapper e libere o wrapper você mesmo

Descobrindo onde a árvore de campos termina

Um /FT local é o teste errado para um campo terminal, porque um pai pode fornecer o tipo e ainda ter campos filhos nomeados. A travessia tentadora para de descer sempre que um nó tem /FT próprio ou não tem /Kids. No formulário de pesquisa, group tem tanto /FT /Ch quanto /Kids, então essa travessia o registra como um campo com duas anotações de widget. Os nomes group.a e group.b desaparecem. A contagem de campos é 1. Uma busca pelo nome do filho falha, e uma escrita de valor só pode cair no pai compartilhado

O HotPDF olha os kids em vez disso. Um kid conta como campo filho se ele tem /T próprio, tem /Kids próprio, ou não é um dicionário /Subtype /Widget. Só quando nenhum kid se qualifica o nó é terminal, e seus kids são então tratados como seus widgets. Os dois casos de borda por trás dessa regra vêm de dicionários mesclados, que a §12.7.3.1 permite quando um campo tem exatamente um widget:

  • Um dicionário mesclado nomeado carrega /Subtype /Widget e ainda assim é um campo filho. O subtipo sozinho não pode empurrá-lo para a lista anônima de widgets do pai; o /T decide
  • Alguns produtores repetem o /FT do pai em todo widget anônimo. Então um /FT em um widget também não pode ser lido como "um novo campo começa aqui"

A mesma classificação dirige o índice de campos, o FormFieldExists e o RemoveFormField. Cada uma dessas caminhadas também registra os dicionários que já visitou e desiste depois de 128 níveis. Suponha que o grupo é o objeto 5 e se lista duas vezes entre os próprios kids: /Kids [5 0 R 5 0 R 6 0 R 7 0 R]. Ele ainda reporta exatamente dois campos, os filhos 6 e 7. Sem recursão infinita, e sem um terceiro campo fantasma vindo da autorreferência

Como o RemoveFormField evita apagar campos irmãos?

O RemoveFormField apaga apenas o filho que você nomeia porque descoberta e remoção compartilham uma definição de campo terminal. O overload por nome resolve um índice de campo a partir da lista de campos, depois percorre /AcroForm /Fields uma segunda vez contando campos terminais para achar o dicionário a apagar. Se a segunda caminhada usasse a regra tentadora do /FT enquanto a primeira usava a regra baseada em kids, os índices discordariam: a caminhada de remoção veria group como campo 0, e remover group.a levaria embora o pai, o irmão e os dois widgets

Então a caminhada de remoção aplica o mesmo teste de campo filho e o mesmo conjunto de visitados que a descoberta. Ela coleta as anotações de widget que pertencem ao filho removido, tira apenas essas do /Annots de cada página, e remove o pai apenas quando o array /Kids dele termina vazio. Com group.b ainda listado, o pai fica, e o tipo, os flags e as opções que group.b herda ficam com ele

Diagrama de sobrevivência de irmãos no RemoveFormField do HotPDF: a caminhada de remoção usa o mesmo teste de campo filho e conjunto de visitados que a descoberta, tira apenas 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 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 uma atualização incremental
// Remove um filho nomeado; o irmão dele e o pai compartilhado sobrevivem
Pdf.RemoveFormField('group.a');

// Checagens de sanidade que você mesmo adicionaria, não fazem parte da biblioteca
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');

Quando você verifica uma remoção nos seus próprios formulários, olhe em três lugares, porque cada um pega um erro diferente: o /Kids do pai, o /Annots da página, e o valor e a aparência do irmão sobrevivente. Faça isso uma vez depois do SaveLoadedDocument e uma vez depois do SaveIncrementalUpdate, já que um salvamento incremental anexa apenas os objetos mudados e o /Kids do pai precisa estar entre eles

O que o ResetLoadedFormField copia de um /DV 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 completo antes de tocar no campo. O default de um checkbox é um name como /Yes. O default de um list box multi-select é um array de strings. Um default de texto pode ser uma string UTF-16 hexadecimal como <FEFF...>. Passe qualquer um deles de volta por GetLoadedFormFieldDefaultValue e SetFormFieldValue e você recebe uma string onde um name pertence, uma string vazia onde havia um array, ou dígitos hex literais

O reset, portanto, ramifica conforme o que o /DV herdado realmente é:

  • Campos de texto e choice com um default de string recebem um novo objeto string que mantém o flag hexadecimal do original
  • Campos choice com um default de array recebem um novo array de novas strings; um array que contém qualquer coisa que não seja string levanta uma exceção
  • Checkboxes e radio buttons recebem um novo objeto name
  • Um default de qualquer outro tipo levanta uma exceção, e /V e /I ficam exatamente como estavam
Diagrama do reset tipado do HotPDF: o ResetLoadedFormField ramifica pelo tipo de objeto do /DV herdado, escrevendo um novo objeto name para um checkbox, um novo array de novas strings 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 sem tocar em /V ou /I em uma incompatibilidade 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 seguem o caminho de string simples

Copiar em vez de referenciar é deliberado, e é a única opção segura. Um /V de filho que apontasse para o array /DV do pai compartilharia um objeto entre "valor atual" e "valor default"; o próximo SetFormFieldValue nesse filho reescreveria silenciosamente o default de todo irmão. Alguns bytes extras por campo de reset é um preço barato por manter os dois separados. Pushbuttons, que não têm valor (Tabela 226, bit 17), e campos de assinatura ficam em um caminho simples que escreve uma cópia em string do próprio /DV deles

Quando não existe /DV em lugar nenhum subindo pela cadeia, o método escreve uma string vazia local, ou /Off para um campo checkbox ou radio. Apagar o /V do filho pareceria mais arrumado e daria a resposta errada. Se o pai guarda um valor atual, remover a sobreposição do filho traz esse valor de volta direto, e o campo "resetado" agora mostra algo que o usuário nunca digitou nele

var
  Field: THPDFLoadedFormField;
begin
  // O pai 'palette' é um list box MultiSelect com
  // /Opt [(b) (g) (r)] e /DV [(b) (r)]; o filho guarda apenas /T e /V
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // O filho agora tem seu próprio /V [(b) (r)] e /I [0 2];
      // o array /DV do pai permanece intocado
    except
      on E: Exception do
        // Default malformado (tipo errado, item de array não string):
        // /V e /I foram deixados como estavam
        Writeln('Reset skipped: ', E.Message);
    end;
    // O getter escalar não consegue representar um default de array
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vazio
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Um reset de campo único também é uma coisa diferente da ação ResetForm da §12.7.5.3, que um visualizador executa sobre um conjunto de campos quando o usuário clica em um botão; essa ação é coberta em construindo campos e ações AcroForm com HotPDF. O ResetLoadedFormField edita um campo carregado, segue a própria regra dele para o caso sem default, e marca o campo como mudado para que o recálculo incremental o pegue

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, e o ResetLoadedFormField termina com os mesmos dois passos de acompanhamento do SetFormFieldValue. Para campos choice, ele remove o /I local do filho, casa cada valor com a metade de export de cada entrada /Opt, e grava um novo /I ordenado. Resetar para [(b) (r)] contra as opções b, g, r rende /I [0 2]. O /I herdado do pai, se houver, permanece intocado

Botões funcionam a partir do tipo herdado. Um checkbox filho cujo /FT /Btn mora no pai ainda tem o /AS atualizado. No lado da escrita, SetFormFieldValue e SetLoadedFormFieldDefaultValue armazenam um objeto name para um botão herdado que não é pushbutton mesmo quando o filho não tem entrada local da qual copiar o tipo. E quando o EnsureLoadedFieldAppearanceStream reconstrói aparências de botões, ele grava /AS /Off a menos que o valor case com o estado ligado, e dá a cada state stream /Type /XObject, /Subtype /Form e um /BBox. Sem essa checagem de /Off, regenerar aparências depois de um reset mostraria a caixa marcada de novo antes de o arquivo ser sequer salvo

Getters escalares e outras bordas para planejar em volta

Os getters de string continuam escalares, e código que compara a saída deles deve saber o que eles achacam. GetFormFieldValue e GetLoadedFormFieldDefaultValue retornam uma string vazia para um valor de array, transformam números e booleanos em 42 ou true, e reportam uma string codificada em hex em sua grafia hexadecimal. Se você precisa saber se um default de list box guarda duas seleções ou nenhuma, o getter de default não pode dizer: ambas voltam como string vazia

Um ciclo de /Parent encerra a busca sem levantar, então um campo cujo tipo se perdeu dentro de um ciclo reporta lfftUnknown e flags 0. Trate lfftUnknown em um campo nomeado como sinal de um arquivo danificado que vale registro em log. SetFormFieldValue e ResetLoadedFormField sempre escrevem o filho que você endereça e nunca promovem um valor para o pai compartilhado. Isso é o certo para filhos independentes como as duas drop-downs da pesquisa. Radio groups são diferentes: a seleção pertence ao campo que possui o grupo, então enderece esse campo em vez de um dos kids dele

Cada chamada também commita um campo por conta própria. Um loop de resets sobre vinte campos são vinte edições independentes; se a quinta levanta em um default malformado, as quatro primeiras já foram aplicadas. Para trabalho em lote, leia o tipo e o default de todos os alvos primeiro, decida quais campos você vai tocar, e só então comece a escrever

A busca de atributos herdados, a classificação da árvore de campos baseada em kids e o reset tipado pertencem todos à API de formulários carregados do HotPDF Delphi Component, que também cobre projetos Delphi e C++Builder que precisam adicionar novos campos a um documento existente, como mostrado em adicionar campos AcroForm a um PDF carregado no Delphi