Articolo tecnico

Valori di campo AcroForm ereditati e reset in Delphi

HotPDF Delphi Component tratta /FT, /Ff, /V e /DV di un campo AcroForm caricato come attributi ereditabili, risolti percorrendo la catena dei /Parent. Dalla v2.754.3 e v2.754.4, un figlio con nome il cui tipo viene dal genitore resta indirizzabile individualmente, RemoveFormField lascia in pace i suoi fratelli, e ResetLoadedFormField copia il default ereditato con il suo tipo di oggetto PDF originale. Prima di allora, un numero sorprendente di form ordinari veniva letto male

Il form che mette in luce tutto questo non è esotico. Uno strumento di authoring costruisce un nodo di gruppo group che porta /FT /Ch, i field flag e la lista di opzioni una volta sola, e vi appende sotto due figli con nome a e b, ciascuno un dizionario campo più widget fuso che non ha altro se non /T, /Parent, /Rect e il suo /V. È un modo perfettamente legale di condividere attributi, ed è esattamente il caso che la sezione Limits di impostare i valori dei campi form in un PDF caricato con Delphi segnava come non gestito: la riconciliazione dei button guardava solo il /FT locale. Questo articolo riprende da dove quello si fermò, coprendo come l'albero dei campi viene classificato, come i valori ereditati vengono letti, e che cosa un reset di campo singolo ha il permesso di scrivere

Quali voci AcroForm può ereditare un campo dal suo genitore?

ISO 32000-1 §12.7.3.1, Table 220, segna /FT, /Ff, /V e /DV come ereditabili, e la Table 229 in §12.7.4.3 fa lo stesso per /MaxLen di un campo testo, quindi qualunque reader che guardi solo il dizionario locale riporterà il tipo sbagliato, i flag sbagliati e un valore vuoto per un figlio perfettamente valido. HotPDF incanala tutte queste letture in un resolver interno, HPDFLoadedInheritedFieldObject, che controlla se il dizionario ha la chiave, risolve un riferimento indiretto se lo trova, e altrimenti segue /Parent per al massimo 128 livelli, perché file malformati possono costruire cicli di /Parent che non c'entrano niente con /Kids. I getter pubblici poggiano su di lui: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue e gli helper di opzioni GetLoadedFormFieldOptionCount e GetLoadedFormFieldOptions, che raccolgono anche un array /Opt memorizzato sul genitore. Una regola del resolver è facile sbagliare: la camminata si ferma al primo dizionario che contiene la chiave, anche se il valore lì è una stringa vuota. Un /V () locale è un override deliberato che maschera il genitore, non un buco da riempire risalendo l'albero

Diagramma degli attributi AcroForm ereditati in HotPDF: un nodo di gruppo porta /FT, /Ff e /Opt una volta sola mentre i figli con nome group.a e group.b tengono solo /T, /Parent, /Rect e un /V locale, mostrando HPDFLoadedInheritedFieldObject che risale il /Parent fino a 128 livelli dove vince il primo dizionario con la chiave e un valore locale vuoto maschera il genitore
HotPDF risolve /FT, /Ff, /V, /DV e /Opt attraverso un unico resolver che risale il genitore, così un figlio con nome resta indirizzabile mentre un valore locale vuoto sovrascrive deliberatamente tutto ciò che porta il gruppo sopra di lui
var
  Pdf: THotPDF;
  Field: THPDFLoadedFormField;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    // 'group' porta /FT /Ch, /Ff 131078 e /Opt; il figlio
    // 'group.b' porta solo /T, /Parent, /Rect e il suo /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));       // il /V locale
      end;
    finally
      Field.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Perché un /FT locale è il test sbagliato per un campo terminale?

Perché un genitore può fornire il tipo e possedere comunque campi figli con nome, quindi la presenza di /FT non dice nulla su dove finisce l'albero dei campi. La vecchia traversata dichiarava terminale un nodo ogni volta che aveva un suo /FT o nessun /Kids. Nel form qui sopra, group ha sia /FT /Ch sia /Kids, quindi veniva registrato come un unico campo di nome group con due widget, e i nomi pienamente qualificati group.a e group.b sparivano e basta. GetFormFieldCount restituiva 1, una ricerca per nome del figlio falliva, e SetFormFieldValue poteva scrivere soltanto il genitore condiviso. Il test sostitutivo, HPDFLoadedFieldHasChildFields, guarda i kid anziché il genitore: un kid è un campo figlio se ha un suo /T, ha un suo /Kids, o non è per niente un dizionario /Subtype /Widget. Solo quando nessun kid si qualifica il nodo è terminale, con i suoi kid trattati come annotazioni widget

I due casi limite che hanno plasmato quella regola vengono entrambi dai dizionari fusi, che §12.7.3.1 consente quando un campo ha un widget singolo. Un dizionario fuso con nome porta /Subtype /Widget e resta comunque un campo figlio, quindi il sottotipo da solo non può mandarlo nella lista dei widget anonimi del genitore; vince il /T. Vale anche il contrario: alcuni producer ripetono il /FT del genitore su ogni widget anonimo, quindi /FT non può servire come prova che un widget apra un campo nuovo. La classificazione è condivisa dalla cache delle relazioni, da FormFieldExists e da RemoveFormField, e ognuna di quelle camminate ora registra i dizionari già visitati e si ferma oltre i 128 livelli. Un file di regression il cui gruppo elenca se stesso due volte, /Kids [5 0 R 5 0 R 6 0 R 7 0 R], riporta ancora esattamente due campi invece di ricorrere all'infinito o contare due volte lo stesso nodo

Come fa RemoveFormField a non cancellare i campi fratelli?

RemoveFormField ora cancella solo il figlio che nomini, perché scoperta e cancellazione finalmente concordano su che cos'è un campo terminale. Quell'accordo conta più di quanto sembri. L'overload per nome risolve un indice attraverso la cache delle relazioni e poi conta i campi terminali in una seconda camminata su /AcroForm /Fields. Una volta sistemata la cache per vedere group.a e group.b, una camminata di cancellazione non sistemata avrebbe comunque trattato group come un unico campo terminale, e l'indice 0 avrebbe rimosso il genitore insieme a ogni fratello e a tutti i loro widget. La camminata di cancellazione ora usa lo stesso test HPDFLoadedFieldHasChildFields e lo stesso insieme dei visitati, raccoglie le annotazioni widget del solo figlio rimosso, le togli dagli /Annots di ogni pagina, e rimuove il genitore solo quando il suo array /Kids finisce vuoto. La regression controlla tutti e tre i posti dove un errore si vedrebbe: i /Kids del genitore, gli /Annots della pagina, e il valore e l'appearance del fratello sopravvissuto, sia dopo una riscrittura completa sia dopo un aggiornamento incrementale

Diagramma della sopravvivenza dei fratelli in HotPDF RemoveFormField: la camminata di cancellazione riusa HPDFLoadedFieldHasChildFields e l'insieme dei visitati della scoperta, togli solo il figlio con nome group.a da AcroForm /Fields e dagli /Annots della pagina, e mantiene il genitore condiviso finché il suo array /Kids tiene ancora group.b
Scoperta e cancellazione finalmente concordano su che cos'è un campo terminale, quindi rimuovere un figlio con nome lascia intatti valore e appearance del fratello dopo una riscrittura completa o un aggiornamento incrementale
// Rimuovi un figlio per nome; il fratello e il genitore condiviso sopravvivono
Pdf.RemoveFormField('group.a');

Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Tipo, flag e opzioni vengono risolti ancora attraverso il genitore
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');

Che cosa scrive ResetLoadedFormField quando il default è ereditato?

ResetLoadedFormField scrive un /V locale che è una copia fresca del /DV ereditato con lo stesso tipo di oggetto PDF, e valida l'intero default prima di toccare il campo. Il tipo di oggetto conta perché i getter scalari appiattiscono tutto a testo. Il default di una checkbox è un name come /Yes, quello di una list box multi-select è un array di stringhe, e quello di un testo può essere una stringa UTF-16 esadecimale; copiare uno qualsiasi di essi attraverso GetLoadedFormFieldDefaultValue trasformerebbe il name in una stringa, l'array in una stringa vuota e la stringa esadecimale nelle sue cifre letterali. Quindi il reset si ramifica sul tipo ereditato: i campi testo e choice ricevono un nuovo oggetto stringa che mantiene il flag IsHexadecimal, i campi choice con default array ricevono un nuovo array di nuove stringhe, e i button non pushbutton ricevono un nuovo oggetto name. Copiare, anziché puntare agli oggetti del genitore, è voluto: un /V che condividesse l'array /DV del genitore o il suo numero di oggetto cambierebbe il default la prossima volta che qualcuno modifica il valore. Un default di tipo sbagliato, o un array choice che contiene altro che stringhe, alza un'eccezione e lascia /V e /I esattamente come erano. I pushbutton, che non hanno valore (Table 226, bit 17), e i campi signature ripiegano sul vecchio percorso solo-stringa

Diagramma del reset tipizzato in HotPDF: ResetLoadedFormField si ramifica sul tipo di oggetto del /DV ereditato, scrivendo un nuovo oggetto name per una checkbox, un nuovo array di nuove stringhe per un choice multi-select, una stringa che mantiene IsHexadecimal per il testo esadecimale, una stringa vuota o /Off quando non esiste /DV, e alzando un'eccezione senza toccare /V o /I su un tipo non corrispondente
Copiare anziché puntare agli oggetti del genitore impedisce che una successiva modifica del valore cambi in silenzio il default, e i pushbutton più i campi signature ripiegano sul vecchio percorso solo-stringa

Quando non esiste /DV da nessuna parte lungo la catena, il metodo mantiene il suo contratto di pulizia scrivendo una stringa vuota locale, o /Off per un campo checkbox o radio. Cancellare il /V locale sembrerebbe più ordinato e sarebbe sbagliato: il genitore può tenere un valore corrente, e togliere l'override del figlio riporterebbe quel valore in silenzio. È anche per questo che un reset di campo singolo non è l'azione ResetForm di §12.7.5.3, che un viewer esegue su un insieme di campi quando l'utente preme un button, come descritto in costruire campi e azioni AcroForm con HotPDF. ResetLoadedFormField è un'operazione di editing su un campo caricato, con la sua regola propria per il caso senza default, e registra il campo attraverso NoteLoadedFormFieldDirty così il ricalcolo incrementale vede il cambiamento

var
  Field: THPDFLoadedFormField;
begin
  Field := Pdf.GetFormField('group.a');
  try
    // Il genitore tiene /DV [(b) (r)] su una list box MultiSelect: group.a riceve
    // il suo /V [(b) (r)] e un nuovo /I [0 2]; il genitore resta intoccato
    Pdf.ResetLoadedFormField(Field.Index);
    // I getter scalari non possono rappresentare il default array
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vuoto
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Tenere /V, /I e /AS d'accordo

Un reset è corretto solo se l'indice di selezione e lo stato di appearance seguono il valore, quindi ResetLoadedFormField chiude con gli stessi due reconciler di SetFormFieldValue. HPDFReconcileChoiceSelection ora accetta un valore array: cancella il /I locale senza mutarlo, incrocia ogni valore con la metà di export di ogni voce /Opt, e scrive un nuovo /I ordinato, quindi un reset a [(b) (r)] contro opzioni b, g, r produce /I [0 2]. ReconcileLoadedButtonAppearanceStates ora chiede il tipo ereditato, così una checkbox figlia il cui /FT /Btn sta sul genitore finalmente riceve il suo /AS. Sul lato scrittura, SetFormFieldValue e SetLoadedFormFieldDefaultValue memorizzano un oggetto name per un button non pushbutton ereditato anche quando il figlio non ha nessuna voce locale da cui copiare il tipo. E quando EnsureLoadedFieldAppearanceStream ricostruisce le appearance dei button, scrive /AS /Off salvo che il valore combaci con lo stato on, e dà a ogni state stream un proprio /Type /XObject, /Subtype /Form e /BBox; prima della v2.754.4, rigenerare l'appearance dopo un reset poteva ri-spuntare la casella prima che il file fosse salvato

Limiti da conoscere prima di costruire su questo

I getter scalari restano scalari. GetFormFieldValue e GetLoadedFormFieldDefaultValue restituiscono una stringa vuota per un valore array, stringificano numeri e booleani come 42 o true, e riportano una stringa codificata es nella sua grafia esadecimale. Un ciclo di /Parent termina la camminata senza eccezione, quindi un campo il cui tipo si perde in un ciclo riporta lfftUnknown e flag a 0 anziché fallire. SetFormFieldValue e ResetLoadedFormField scrivono sempre il figlio che indirizzi e non promuovono mai un valore al genitore condiviso, che è giusto per figli indipendenti ma significa che i gruppi radio vanno indirizzati attraverso il campo che possiede la selezione. E ogni chiamata commette un campo per conto suo; niente di tutto ciò rende transazionale un batch di reset

La risoluzione degli attributi ereditati, la classificazione unificata dell'albero dei campi e il reset tipizzato descritti qui fanno parte della API dei form caricati in HotPDF Delphi Component per Delphi e C++Builder, accanto alla creazione dei campi coperta in aggiungere campi AcroForm a un PDF caricato in Delphi