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
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
// 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
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