Articolo tecnico

Valori di campo AcroForm ereditati e reset in Delphi

Un form di sondaggio mostra in Acrobat due liste a discesa, group.a e group.b. Percorri /AcroForm /Fields con codice che tratta qualunque nodo che porti un proprio /FT come un campo finito, e il conteggio torna 1: un solo campo chiamato group con due widget. Chiedi group.b per nome e la ricerca fallisce. Apri il dizionario del figlio e dentro non c'è nessun /FT, nessun /Ff, nessuna lista di opzioni. Nulla è rotto. Il tipo, i flag e le opzioni stanno un livello sopra, sul genitore, e la specifica PDF permette esplicitamente che vivano lì

Il layout è legale ed economico. Uno strumento di authoring scrive una volta sola un nodo di gruppo con /FT /Ch, /Ff 131078 e un array /Opt, poi appende sotto due figli con nome, ognuno un dizionario fuso campo-più-widget che contiene solo /T, /Parent, /Rect e il proprio /V. Leggere correttamente un form del genere richiede tre cose: seguire /Parent per ogni chiave ereditabile, decidere dove finisce davvero l'albero dei campi, e assicurarsi che un reset scriva un valore del giusto tipo PDF. Questo pezzo riprende da dove si ferma impostare i valori dei campi form in un PDF caricato con Delphi

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 il /MaxLen di un campo testo. Gli array di opzioni ricevono qui lo stesso trattamento, perché i producer che condividono una lista choice tra i figli memorizzano /Opt sul gruppo. Un reader che controlla solo il dizionario locale riporterà quindi il tipo sbagliato, flag a 0 e un valore vuoto per un figlio perfettamente valido

HotPDF Delphi Component risponde a tutte quelle letture allo stesso modo. Cerca la chiave nel dizionario proprio del campo e, se la chiave c'è, risolve il riferimento indiretto e si ferma. Altrimenti segue /Parent e riprova, fino a un massimo di 128 livelli. Quel tetto non è decorazione. I puntatori /Parent nei file danneggiati possono formare un loop che non c'entra nulla con /Kids, e un reader che si fida di loro incondizionatamente si impalla sul primo file del genere che incontra

Una regola di quella passeggiata è facile da capire al contrario: vince il primo dizionario che contiene la chiave, anche quando il valore memorizzato lì è una stringa vuota. Un /V () locale su group.b è l'autore che dice "questo figlio è vuoto", e la passeggiata si ferma lì invece di continuare a salire verso un valore che il genitore potrebbe tenere. Trattare il vuoto come "assente" farebbe visualizzare a un figlio ripulito la selezione del genitore, il contrario di ciò che l'autore ha scritto

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

Tutti i getter pubblici poggiano su quella ricerca: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue, e gli helper delle opzioni GetLoadedFormFieldOptionCount e GetLoadedFormFieldOptions. Così leggere il figlio del gruppo del sondaggio non richiede codice speciale:

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;

GetFormField restituisce ogni volta un nuovo wrapper THPDFLoadedFormField e solleva un'eccezione quando il nome è sconosciuto: tieni la chiamata fuori dal blocco try del wrapper e libera tu stesso il wrapper

Trovare dove finisce l'albero dei campi

Un /FT locale è il test sbagliato per un campo terminale, perché un genitore può fornire il tipo e possedere comunque campi figli con nome. L'attraversamento tentante smette di scendere ogni volta che un nodo ha un proprio /FT o non ha /Kids. Nel form del sondaggio, group ha sia /FT /Ch che /Kids, così quell'attraversamento lo registra come un campo con due annotazioni widget. I nomi group.a e group.b svaniscono. Il conteggio dei campi è 1. Una ricerca per nome del figlio fallisce, e una scrittura di valore può atterrare solo sul genitore condiviso

HotPDF guarda invece ai kids. Un kid conta come campo figlio se ha un proprio /T, ha un proprio /Kids, o non è un dizionario /Subtype /Widget. Solo quando nessun kid si qualifica il nodo è terminale, e i suoi kids vengono allora trattati come suoi widget. Entrambi i casi limite dietro quella regola vengono dai dizionari fusi, che §12.7.3.1 permette quando un campo ha esattamente un widget:

  • Un dizionario fuso con nome porta /Subtype /Widget ed è comunque un campo figlio. Il sottotipo da solo non può spingerlo nella lista di widget anonimi del genitore; a decidere è il /T
  • Alcuni producer ripetono il /FT del genitore su ogni widget anonimo. Così un /FT su un widget non si può leggere nemmeno come "qui inizia un nuovo campo"

La stessa classificazione guida l'indice dei campi, FormFieldExists e RemoveFormField. Ognuno di quei percorsi registra anche i dizionari già visitati e si arrende oltre 128 livelli. Supponi che il gruppo sia l'oggetto 5 e elenchi se stesso due volte tra i propri kids: /Kids [5 0 R 5 0 R 6 0 R 7 0 R]. Riporta comunque esattamente due campi, i figli 6 e 7. Nessuna ricorsione infinita, e nessun terzo campo fantasma dall'auto-riferimento

Come fa RemoveFormField a non cancellare i campi fratelli?

RemoveFormField cancella solo il figlio che nomini perché scoperta e cancellazione condividono un'unica definizione di campo terminale. L'overload per nome risolve un indice di campo dalla lista dei campi, poi percorre /AcroForm /Fields una seconda volta contando i campi terminali per trovare il dizionario da cancellare. Se il secondo percorso usasse la regola tentante del /FT mentre il primo usa la regola basata sui kids, gli indici sarebbero in disaccordo: il percorso di cancellazione vedrebbe group come campo 0, e rimuovere group.a porterebbe via il genitore, il fratello ed entrambi i widget

Così il percorso di cancellazione applica lo stesso test di campo figlio e lo stesso insieme di visitati della scoperta. Raccoglie le annotazioni widget che appartengono al figlio rimosso, toglie solo quelle dagli /Annots di ogni pagina, e rimuove il genitore solo quando il suo array /Kids finisce vuoto. Con group.b ancora elencato, il genitore resta, e il tipo, i flag e le opzioni che group.b eredita restano con lui

Diagramma della sopravvivenza dei fratelli in HotPDF RemoveFormField: il percorso di cancellazione usa lo stesso test di campo figlio e lo stesso insieme di visitati della scoperta, toglie solo il figlio con nome group.a da AcroForm /Fields e dagli /Annots di pagina, e tiene il genitore condiviso finché il suo array /Kids contiene ancora group.b sopravvissuto
Scoperta e cancellazione sono d'accordo su cosa è un campo terminale, così rimuovere un figlio con nome lascia il valore e l'appearance del fratello intatti dopo una riscrittura completa o un incremental update
// Rimuovi un figlio con nome; il fratello e il genitore condiviso sopravvivono
Pdf.RemoveFormField('group.a');

// Controlli di sanità che aggiungeresti tu, non parte della libreria
Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Tipo, flag e opzioni sono ancora risolti attraverso il genitore
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);

Pdf.SaveLoadedDocument('survey-trimmed.pdf');

Quando verifichi una rimozione sulle tue form, guarda in tre posti, perché ognuno becca un errore diverso: i /Kids del genitore, gli /Annots di pagina, e il valore e l'appearance del fratello sopravvissuto. Fallo una volta dopo SaveLoadedDocument e una dopo SaveIncrementalUpdate, dato che un salvataggio incrementale fa append solo degli oggetti cambiati e i /Kids del genitore devono esserci

Che cosa copia ResetLoadedFormField da un /DV ereditato

ResetLoadedFormField scrive un /V locale che è una copia fresca del /DV ereditato, con lo stesso tipo di oggetto PDF, e valida il default completo prima di toccare il campo. Il default di una checkbox è un nome come /Yes. Il default di una list box multi-select è un array di stringhe. Un default di testo può essere una stringa UTF-16 esadecimale come <FEFF...>. Fai fare un round trip a uno qualsiasi di questi attraverso GetLoadedFormFieldDefaultValue e SetFormFieldValue e ottieni una stringa dove deve stare un nome, una stringa vuota dove c'era un array, o cifre hex letterali

Il reset quindi si ramifica su ciò che il /DV ereditato è davvero:

  • I campi testo e choice con un default stringa ricevono un nuovo oggetto stringa che conserva il flag esadecimale dell'originale
  • I campi choice con un default array ricevono un nuovo array di nuove stringhe; un array che contiene altro che stringhe solleva un'eccezione
  • Le checkbox e i radio button ricevono un nuovo oggetto name
  • Un default di qualsiasi altro tipo solleva un'eccezione, e /V e /I restano esattamente come erano
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 una choice multi-select, una stringa che conserva IsHexadecimal per il testo hex, una stringa vuota o /Off quando non esiste /DV, e sollevando senza toccare /V o /I in caso di tipo non combaciante
Copiare invece di puntare agli oggetti del genitore impedisce che una successiva modifica del valore cambi in silenzio il default, e i pushbutton più i campi firma prendono il percorso semplice della stringa

Copiare invece di referenziare è deliberato, ed è l'unica opzione sicura. Un /V figlio che puntasse all'array /DV del genitore condividerebbe un oggetto tra "valore corrente" e "valore predefinito"; la successiva SetFormFieldValue su quel figlio riscriverebbe in silenzio il default di ogni fratello. Qualche byte in più per campo di reset è un prezzo basso per tenere i due separati. I pushbutton, che non hanno valore (Table 226, bit 17), e i campi firma restano su un percorso semplice che scrive una copia stringa del loro proprio /DV

Quando non esiste /DV da nessuna parte lungo la catena, il metodo scrive una stringa vuota locale, o /Off per un campo checkbox o radio. Cancellare il /V del figlio sembrerebbe più ordinato e darebbe la risposta sbagliata. Se il genitore tiene un valore corrente, rimuovere l'override del figlio porta quel valore dritto indietro, e il campo "resettato" ora mostra qualcosa che l'utente non ha mai digitato dentro

var
  Field: THPDFLoadedFormField;
begin
  // Il genitore 'palette' è una list box MultiSelect con
  // /Opt [(b) (g) (r)] e /DV [(b) (r)]; il figlio tiene solo /T e /V
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // Il figlio ora ha il suo /V [(b) (r)] e /I [0 2];
      // l'array /DV del genitore resta intatto
    except
      on E: Exception do
        // Default malformato (tipo sbagliato, voce array non stringa):
        // /V e /I sono stati lasciati come erano
        Writeln('Reset skipped: ', E.Message);
    end;
    // Il getter scalare non può rappresentare un default array
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // vuoto
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Un reset di campo singolo è anche una cosa diversa dall'azione ResetForm di §12.7.5.3, che un viewer esegue su un insieme di campi quando l'utente preme un pulsante; quell'azione è trattata in costruire campi e azioni AcroForm con HotPDF. ResetLoadedFormField modifica un campo caricato, segue la sua regola propria per il caso senza default, e marca il campo come cambiato così il ricalcolo incrementale lo raccoglie

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

Un reset è corretto solo se l'indice di selezione e lo stato di appearance seguono il valore, e ResetLoadedFormField finisce con gli stessi due passi successivi di SetFormFieldValue. Per i campi choice, rimuove il /I locale del figlio, combacia ogni valore con la metà di export di ogni voce /Opt, e scrive un nuovo /I ordinato. Resettare a [(b) (r)] contro le opzioni b, g, r produce /I [0 2]. Il /I ereditato del genitore, se c'è, resta intatto

I bottoni lavorano dal tipo ereditato. Una checkbox figlia il cui /FT /Btn sta sul genitore ottiene comunque il suo /AS aggiornato. Sul lato scrittura, SetFormFieldValue e SetLoadedFormFieldDefaultValue memorizzano un oggetto name per un bottone non-pushbutton ereditato anche quando il figlio non ha una voce locale da cui copiare il tipo. E quando EnsureLoadedFieldAppearanceStream ricostruisce le appearance dei bottoni, scrive /AS /Off a meno che il valore combaci con lo stato on, e dà a ogni stream di stato /Type /XObject, /Subtype /Form e un /BBox. Senza quel controllo /Off, rigenerare le appearance dopo un reset mostrerebbe la casella di nuovo spuntata prima ancora che il file venga salvato

Getter scalari e altri bordi da pianificare

I getter stringa restano scalari, e il codice che confronta il loro output dovrebbe sapere cosa appiattiscono. GetFormFieldValue e GetLoadedFormFieldDefaultValue restituiscono una stringa vuota per un valore array, trasformano numeri e booleani in 42 o true, e riportano una stringa hex-encoded nella sua grafia esadecimale. Se devi sapere se il default di una list box contiene due selezioni o nessuna, il getter del default non può dirtelo: entrambi tornano come stringa vuota

Un ciclo di /Parent chiude la ricerca senza sollevare, così un campo il cui tipo si perde dentro un ciclo riporta lfftUnknown e flag a 0. Tratta lfftUnknown su un campo con nome come un segno di file danneggiato che vale un log. SetFormFieldValue e ResetLoadedFormField scrivono sempre il figlio che indirizzi e non promuovono mai un valore al genitore condiviso. È la cosa giusta per figli indipendenti come le due liste a discesa del sondaggio. I gruppi radio sono diversi: la selezione appartiene al campo che possiede il gruppo, quindi indirizza quel campo piuttosto che uno dei suoi kids

Ogni chiamata peraltro esegue il commit di un campo per volta. Un ciclo di reset su venti campi è venti modifiche indipendenti; se la quinta solleva su un default malformato, le prime quattro sono già applicate. Per il lavoro in batch, leggi prima tipo e default di ogni target, decidi quali campi toccherai, e solo allora inizia a scrivere

La ricerca degli attributi ereditati, la classificazione dell'albero dei campi basata sui kids e il reset tipizzato appartengono tutti alla API dei form caricati di HotPDF Delphi Component, che copre anche i progetti Delphi e C++Builder che devono aggiungere nuovi campi a un documento esistente, come mostrato in aggiungere campi AcroForm a un PDF caricato in Delphi