O HotPDF Delphi Component trata o /FT, o /Ff, o /V e o /DV num campo AcroForm carregado como atributos herdados, resolvidos percorrendo a cadeia de /Parent. Desde a v2.754.3 e a v2.754.4, um filho nomeado cujo tipo vem do pai continua individualmente endereçável, o RemoveFormField deixa os irmãos em paz, e o ResetLoadedFormField copia o default herdado com o seu tipo de objeto PDF original. Antes disso, um número surpreendente de formulários vulgares estava a ser mal lido
O formulário que expõe tudo isto não é exótico. Uma ferramenta de autoria constrói um nó de grupo group que transporta /FT /Ch, as field flags e a lista de opções uma única vez, e pendura dois filhos nomeados a e b por baixo, cada um um dicionário fundido de campo mais widget sem nada além de /T, /Parent, /Rect e o seu próprio /V. É uma maneira perfeitamente legal de partilhar atributos, e é exatamente o caso que a secção Limits de definir valores de campos de formulário num PDF carregado com Delphi sinalizou como não tratado: a reconciliação de botões só olhava ao /FT local. Este artigo apanha 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 licença para escrever
Que entradas AcroForm pode um campo herdar do pai?
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, por isso qualquer leitor que só olhe ao dicionário local vai reportar o tipo errado, as flags erradas e um valor vazio para um filho perfeitamente válido. O HotPDF canaliza todas estas leituras por um resolver interno único, o HPDFLoadedInheritedFieldObject, que procura a chave no dicionário, resolve uma referência indireta se encontrar uma, e caso contrário segue o /Parent durante no máximo 128 níveis, porque ficheiros malformados conseguem construir ciclos de /Parent que nada têm a ver com /Kids. Os getters públicos assentam por cima: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue e os helpers de opções GetLoadedFormFieldOptionCount e GetLoadedFormFieldOptions, que também apanham um array /Opt guardado no pai. Uma regra no 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, e não um buraco a preencher mais acima na árvore
var
Pdf: THotPDF;
Field: THPDFLoadedFormField;
begin
Pdf := THotPDF.Create(nil);
try
if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
// 'group' transporta /FT /Ch, /Ff 131078 e /Opt; o filho
// 'group.b' transporta apenas /T, /Parent, /Rect e o seu próprio /V
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;
Porque é que um /FT local é o teste errado para um campo terminal?
Porque um pai pode fornecer o tipo e continuar a ter campos filhos nomeados, por isso a presença de /FT nada diz sobre onde a árvore de campos acaba. A travessia antiga declarava um nó terminal sempre que ele tinha o seu próprio /FT ou não tinha /Kids. No formulário acima, o group tem tanto /FT /Ch como /Kids, por isso era registado como um único campo chamado group com dois widgets, e os nomes completamente qualificados group.a e group.b simplesmente desapareciam. O GetFormFieldCount devolvia 1, uma procura pelo nome do filho falhava, e o SetFormFieldValue só conseguia escrever no pai partilhado. O teste de substituição, o HPDFLoadedFieldHasChildFields, olha aos filhos em vez de ao pai: um kid é um campo filho se tiver o seu próprio /T, tiver o seu próprio /Kids, ou não for um dicionário /Subtype /Widget de todo. Só quando nenhum kid se qualifica é que o nó é terminal, com os kids tratados como as suas anotações de widget
Os dois casos de fronteira que moldaram essa regra vêm ambos de dicionários fundidos, que a §12.7.3.1 permite quando um campo tem um único widget. Um dicionário fundido nomeado transporta /Subtype /Widget e ainda assim é um campo filho, por isso o subtipo sozinho não o pode mandar para a lista anónima de widgets do pai; o /T é que ganha. O inverso também acontece: alguns produtores repetem o /FT do pai em todas as widgets anónimas, por isso o /FT não pode servir de evidência de que uma widget começa um campo novo. A classificação é partilhada pela cache de relações, pelo FormFieldExists e pelo RemoveFormField, e cada uma dessas caminhadas agora regista os dicionários que já visitou e passa dos 128 níveis. Um ficheiro de regressão cujo grupo se lista a si próprio 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 é que o RemoveFormField evita apagar campos irmãos?
O RemoveFormField agora apaga apenas o filho que se nomeia, porque a descoberta e a apagagem finalmente concordam quanto ao que é um campo terminal. Esse acordo interessa mais do que parece. A overload por nome resolve um índice através da cache de relações e depois conta campos terminais numa segunda caminhada sobre /AcroForm /Fields. Uma vez a cache corrigida para ver group.a e group.b, uma caminhada de apagamento por corrigir ainda teria tratado o group como um único campo terminal, e o índice 0 teria removido o pai juntamente com todos os irmãos e todas as suas widgets. A caminhada de apagamento agora usa o mesmo teste HPDFLoadedFieldHasChildFields e o mesmo conjunto de visitados, recolhe as anotações de widget só do filho removido, tira-as dos /Annots de cada página, e remove o pai apenas quando o seu array /Kids acaba vazio. A regressão verifica os três sítios onde um erro apareceria: os /Kids do pai, os /Annots da página, e o valor e a aparência do irmão sobrevivente, tanto depois de uma reescrita completa como depois de uma atualização incremental
// Remover um filho nomeado; o irmão e o pai partilhado sobrevivem
Pdf.RemoveFormField('group.a');
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Tipo, flags e opções continuam resolvidos através do pai
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');
O que escreve o ResetLoadedFormField 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 interessa porque os getters escalares achatam tudo para texto. O default de uma checkbox é um nome como /Yes, o default de uma list box multiseleção é um array de strings, e um default de texto pode ser uma string hexadecimal UTF-16; copiar qualquer deles através do GetLoadedFormFieldDefaultValue transformaria o nome numa string, o array numa string vazia e a string hex nos seus dígitos literais. O reset ramifica portanto pelo tipo herdado: campos de texto e choice recebem um novo objeto string que preserva a flag IsHexadecimal, campos choice com default de array recebem um novo array de novas strings, e botões não pushbutton recebem um novo objeto name. Copiar, em vez de apontar para os objetos do pai, é deliberado: um /V que partilhasse o array /DV do pai ou o seu número de objeto mudaria o default da próxima vez que alguém editasse o valor. Um default do tipo errado, ou um array de choice contendo algo que não strings, levanta uma 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 recuam para o caminho antigo só de strings
Quando não existe /DV nenhum na cadeia toda, o método mantém o seu contrato de limpeza escrevendo uma string vazia local, ou /Off para um campo checkbox ou radio. Apagar o /V local pareceria mais arrumado e estaria errado: o pai pode guardar um valor corrente, e remover a sobreposição do filho traria esse valor de volta em silêncio. É também por isto que um reset de campo único não é a ação ResetForm da §12.7.5.3, que um visualizador corre sobre um conjunto de campos quando o utilizador clica num botão, como descrito em construir campos e ações AcroForm com HotPDF. O ResetLoadedFormField é uma operação de edição sobre um campo carregado, com a sua própria regra para o caso sem default, e registra o campo através do NoteLoadedFormFieldDirty para que o recálculo incremental veja a mudança
var
Field: THPDFLoadedFormField;
begin
Field := Pdf.GetFormField('group.a');
try
// O pai guarda /DV [(b) (r)] numa list box MultiSelect: group.a recebe
// o seu próprio /V [(b) (r)] e um /I [0 2] fresco; o pai fica intacto
Pdf.ResetLoadedFormField(Field.Index);
// Os getters escalares não conseguem representar o default do array
Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vazio
finally
Field.Free;
end;
Pdf.SaveLoadedDocument('survey-reset.pdf');
end;
Manter /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, por isso o ResetLoadedFormField termina com os mesmos dois reconciliadores que o SetFormFieldValue. O HPDFReconcileChoiceSelection agora aceita um valor de array: apaga o /I local sem o mutar, casa cada valor com a metade de exportação de cada entrada /Opt, e escreve um novo /I ordenado, por isso um reset para [(b) (r)] contra as opções b, g, r produz /I [0 2]. O ReconcileLoadedButtonAppearanceStates agora pede o tipo herdado, por isso uma checkbox filha cujo /FT /Btn vive no pai finalmente vê o seu /AS definido. Do lado da escrita, o SetFormFieldValue e o SetLoadedFormFieldDefaultValue guardam um objeto name para um botão herdado 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, escreve /AS /Off a menos que o valor corresponda ao estado ativado, e dá a cada stream de estado um /Type /XObject, /Subtype /Form e /BBox próprios; antes da v2.754.4, regenerar a aparência depois de um reset podia voltar a assinalar a caixa antes de o ficheiro ser gravado
Limites que valem a pena conhecer antes de construir sobre isto
Os getters escalares continuam escalares. O GetFormFieldValue e o GetLoadedFormFieldDefaultValue devolvem uma string vazia para um valor de array, stringificam números e booleanos como 42 ou true, e reportam uma string codificada em hex pela sua grafia hexadecimal. Um ciclo de /Parent termina a caminhada sem exceção, por isso um campo cujo tipo se perdeu num ciclo reporta lfftUnknown e flags de 0 em vez de falhar. O SetFormFieldValue e o ResetLoadedFormField escrevem sempre no filho que se endereça e nunca promovem um valor para o pai partilhado, o que é certo para filhos independentes mas significa que grupos de radio devem ser endereçados através do campo que possui a seleção. E cada chamada compromete um campo por si; 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 aqui descritos fazem parte da API de formulários carregados no HotPDF Delphi Component para Delphi e C++Builder, ao lado da criação de campos coberta em acrescentar campos AcroForm a um PDF carregado no Delphi