Technischer Artikel

AcroForm-geerbte Feldwerte und Resets in Delphi

HotPDF Delphi Component behandelt /FT, /Ff, /V und /DV auf einem geladenen AcroForm-Feld als vererbbare Attribute, aufgelöst durch das Ablaufen der /Parent-Kette. Seit v2.754.3 und v2.754.4 bleibt ein benanntes Kind, dessen Typ von seinem Parent kommt, einzeln adressierbar, RemoveFormField lässt seine Geschwister in Ruhe, und ResetLoadedFormField kopiert den geerbten Default mit seinem originalen PDF-Objekttyp. Davor wurden verblüffend viele gewöhnliche Formulare falsch gelesen

Das Formular, das das alles aufdeckt, ist nichts Exotisches. Ein Authoring-Tool baut einen Gruppenknoten group, der /FT /Ch, die Field-Flags und die Optionsliste einmal trägt, und hängt zwei benannte Kinder a und b darunter, jedes ein verschmolzenes Field-plus-Widget-Dictionary mit nichts als /T, /Parent, /Rect und seinem eigenen /V. Das ist eine völlig legale Art, Attribute zu teilen, und genau der Fall, den der Limits-Abschnitt von Formularfeldwerte in einer geladenen PDF mit Delphi setzen als unbehandelt markiert hatte: Der Button-Abgleich schaute nur auf das lokale /FT. Dieser Artikel macht da weiter, wo jener aufhörte – wie der Field-Tree klassifiziert wird, wie geerbte Werte gelesen werden und was ein Einzelfeld-Reset schreiben darf

Welche AcroForm-Einträge kann ein Feld von seinem Parent erben?

ISO 32000-1 §12.7.3.1, Tabelle 220, markiert /FT, /Ff, /V und /DV als vererbbar, und Tabelle 229 in §12.7.4.3 tut dasselbe für das /MaxLen eines Textfelds, also meldet jeder Reader, der nur auf das lokale Dictionary schaut, den falschen Typ, die falschen Flags und einen leeren Wert für ein völlig valides Kind. HotPDF führt all diese Lesezugriffe durch einen internen Resolver, HPDFLoadedInheritedFieldObject, der das Dictionary auf den Key prüft, eine indirekte Referenz auflöst, wenn er eine findet, und sonst /Parent für höchstens 128 Ebenen folgt, denn fehlerhafte Dateien können /Parent-Zyklen bauen, die mit /Kids nichts zu tun haben. Die öffentlichen Getter sitzen darauf auf: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue und die Options-Helfer GetLoadedFormFieldOptionCount und GetLoadedFormFieldOptions, die auch ein auf dem Parent gespeichertes /Opt-Array einsammeln. Eine Regel im Resolver ist leicht falsch zu verstehen: Der Walk hält am ersten Dictionary an, das den Key enthält, selbst wenn der Wert dort ein leerer String ist. Ein lokales /V () ist eine bewusste Übersteuerung, die den Parent maskiert, keine Lücke, die man von weiter oben füllt

HotPDF-Diagramm geerbter AcroForm-Attribute: Ein Gruppenknoten trägt /FT, /Ff und /Opt einmalig, während die benannten Kinder group.a und group.b nur /T, /Parent, /Rect und ein lokales /V halten, zu sehen an HPDFLoadedInheritedFieldObject, das /Parent bis zu 128 Ebenen abläuft, wobei das erste Dictionary mit einem Key gewinnt und ein leerer lokaler Wert den Parent maskiert
HotPDF löst /FT, /Ff, /V, /DV und /Opt über einen einzigen Parent-Abwärts-Resolver auf, ein benanntes Kind bleibt also adressierbar, während ein leerer lokaler Wert bewusst alles übersteuert, was die Gruppe darüber trägt
var
  Pdf: THotPDF;
  Field: THPDFLoadedFormField;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('survey.pdf') <= 0 then Exit;
    // 'group' trägt /FT /Ch, /Ff 131078 und /Opt; das Kind
    // 'group.b' trägt nur /T, /Parent, /Rect und sein eigenes /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));       // das lokale /V
      end;
    finally
      Field.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Warum ist ein lokales /FT der falsche Test für ein Endfeld?

Weil ein Parent den Typ liefern kann und trotzdem eigene benannte Kind-Felder besitzt, sagt die Anwesenheit von /FT also nichts darüber, wo der Field-Tree endet. Die alte Traversierung erklärte einen Knoten für endgültig, wann immer er sein eigenes /FT oder keine /Kids hatte. Im Formular oben hat group beides, /FT /Ch und /Kids, also wurde es als ein Feld namens group mit zwei Widgets registriert, und die voll qualifizierten Namen group.a und group.b verschwanden schlicht. GetFormFieldCount gab 1 zurück, eine Suche nach dem Kindsnamen scheiterte, und SetFormFieldValue konnte nur den geteilten Parent beschreiben. Der Ersatz-Test, HPDFLoadedFieldHasChildFields, schaut auf die Kids statt auf den Parent: Ein Kid ist ein Kind-Feld, wenn es ein eigenes /T hat, eigene /Kids hat oder gar kein /Subtype /Widget-Dictionary ist. Nur wenn kein Kid zutrifft, ist der Knoten endgültig, mit seinen Kids als Widget-Annotationen

Die zwei Grenzfälle, die diese Regel geformt haben, kommen beide aus verschmolzenen Dictionarys, die §12.7.3.1 erlaubt, wenn ein Feld ein einziges Widget hat. Ein benanntes verschmolzenes Dictionary trägt /Subtype /Widget und ist trotzdem ein Kind-Feld, der Subtype allein darf es also nicht in die anonyme Widget-Liste des Parents abschieben; das /T gewinnt. Umgekehrt passiert es ebenfalls: Manche Producer wiederholen das /FT des Parents an jedem anonymen Widget, also kann /FT nicht als Beweis gelten, dass ein Widget ein neues Feld startet. Die Klassifizierung teilen sich der Relationship-Cache, FormFieldExists und RemoveFormField, und jeder dieser Walks notiert jetzt die Dictionarys, die er schon besucht hat, und stoppt jenseits von 128 Ebenen. Eine Regressionsdatei, deren Gruppe sich selbst doppelt auflistet, /Kids [5 0 R 5 0 R 6 0 R 7 0 R], meldet weiterhin exakt zwei Felder, statt endlos zu rekursieren oder denselben Knoten doppelt zu zählen

Wie vermeidet RemoveFormField, Geschwisterfelder zu löschen?

RemoveFormField löscht jetzt nur das Kind, das Sie nennen, weil Discovery und Löschung endlich darüber einig sind, was ein Endfeld ist. Diese Einigung zählt mehr, als sie aussieht. Der By-Name-Overload löst einen Index über den Relationship-Cache auf und zählt dann Endfelder in einem zweiten Walk über /AcroForm /Fields. Sobald der Cache group.a und group.b sah, hätte ein unfizierter Lösch-Walk group weiterhin als ein einziges Endfeld behandelt, und Index 0 hätte den Parent mitsamt jedem Geschwister und all deren Widgets entfernt. Der Lösch-Walk nutzt jetzt denselben HPDFLoadedFieldHasChildFields-Test und dieselbe Visited-Menge, sammelt nur die Widget-Annotationen des entfernten Kindes ein, streicht sie aus dem /Annots jeder Seite und entfernt den Parent erst, wenn sein /Kids-Array leer endet. Die Regression prüft alle drei Stellen, an denen ein Fehler sichtbar würde: das /Kids des Parents, das /Annots der Seite und Wert und Appearance des überlebenden Geschwisters, jeweils nach einem vollen Rewrite und nach einem inkrementellen Update

HotPDF-Diagramm zum Geschwister-Überleben bei RemoveFormField: Der Lösch-Walk nutzt HPDFLoadedFieldHasChildFields und die Visited-Menge aus der Discovery wieder, streicht nur das benannte Kind group.a aus AcroForm /Fields und den Seiten-/Annots und behält den geteilten Parent, solange sein /Kids-Array noch das überlebende group.b hält
Discovery und Löschung sind sich endlich einig, was ein Endfeld ist, also bleibt beim Entfernen eines benannten Kindes der Wert und das Appearance seines Geschwisters nach einem vollen Rewrite wie nach einem inkrementellen Update intakt
// Ein benanntes Kind entfernen; sein Geschwister und der geteilte Parent überleben
Pdf.RemoveFormField('group.a');

Assert(Pdf.GetFormFieldCount = 1);
Assert(Pdf.FormFieldExists('group.b'));
// Typ, Flags und Optionen werden weiterhin über den Parent aufgelöst
Assert(Pdf.GetFormFieldType('group.b') = lfftChoice);
Pdf.SaveLoadedDocument('survey-trimmed.pdf');

Was schreibt ResetLoadedFormField, wenn der Default geerbt ist?

ResetLoadedFormField schreibt ein lokales /V, das eine frische Kopie des geerbten /DV mit demselben PDF-Objekttyp ist, und es validiert den ganzen Default, bevor es das Feld anfasst. Der Objekttyp zählt, weil die skalaren Getter alles auf Text plätten. Ein Checkbox-Default ist ein Name wie /Yes, ein Multi-Select-Listbox-Default ist ein Array aus Strings, und ein Text-Default kann ein hexadezimaler UTF-16-String sein; irgendeinen davon durch GetLoadedFormFieldDefaultValue zu kopieren, würde aus dem Namen einen String machen, aus dem Array einen leeren String und aus dem Hex-String seine literalen Ziffern. Der Reset verzweigt daher nach dem geerbten Typ: Text- und Choice-Felder bekommen ein neues String-Objekt, das das IsHexadecimal-Flag hält, Choice-Felder mit Array-Default bekommen ein neues Array aus neuen Strings, und Nicht-Pushbutton-Buttons bekommen ein neues Name-Objekt. Zu kopieren, statt auf die Objekte des Parents zu zeigen, ist Absicht: Ein /V, das sich das /DV-Array des Parents oder seine Objektnummer teilte, würde den Default verändern, sobald irgendwer den Wert editiert. Ein Default vom falschen Typ oder ein Choice-Array, das etwas anderes als Strings enthält, wirft eine Exception und lässt /V und /I exakt, wie sie waren. Pushbuttons, die keinen Wert haben (Tabelle 226, Bit 17), und Signaturfelder fallen auf den älteren reinen-String-Pfad zurück

HotPDF-Diagramm des typisierten Resets: ResetLoadedFormField verzweigt nach dem geerbten /DV-Objekttyp, schreibt ein frisches Name-Objekt für eine Checkbox, ein neues Array aus neuen Strings für eine Multi-Select-Choice, einen String, der IsHexadecimal hält, für Hex-Text, einen leeren String oder /Off, wenn kein /DV existiert, und wirft bei Typabweichung, ohne /V oder /I anzufassen
Zu kopieren statt auf die Parent-Objekte zu zeigen verhindert, dass eine spätere Wert-Editierung still den Default verändert, und Pushbuttons samt Signaturfeldern fallen auf den älteren reinen-String-Pfad zurück

Existiert nirgends in der Kette ein /DV, hält die Methode ihren Leervertrag, indem sie einen lokalen leeren String schreibt, oder /Off bei einem Checkbox- oder Radio-Feld. Das lokale /V zu löschen sähe aufgeräumter aus und wäre falsch: Der Parent kann einen aktuellen Wert halten, und das Entfernen der Übersteuerung des Kindes holte diesen Wert still zurück. Deshalb ist ein Einzelfeld-Reset auch nicht die ResetForm-Aktion aus §12.7.5.3, die ein Viewer über eine Feldmenge ausführt, wenn der Nutzer auf einen Button klickt, wie in AcroForm-Felder und Aktionen mit HotPDF bauen beschrieben. ResetLoadedFormField ist eine Editieroperation an einem geladenen Feld, mit eigener Regel für den Fall ohne Default, und es meldet das Feld über NoteLoadedFormFieldDirty, damit die inkrementelle Neuberechnung die Änderung sieht

var
  Field: THPDFLoadedFormField;
begin
  Field := Pdf.GetFormField('group.a');
  try
    // Der Parent hält /DV [(b) (r)] an einer MultiSelect-Listbox: group.a bekommt
    // sein eigenes /V [(b) (r)] und ein frisches /I [0 2]; der Parent bleibt unangetastet
    Pdf.ResetLoadedFormField(Field.Index);
    // Skalare Getter können den Array-Default nicht darstellen
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // leer
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

/V, /I und /AS im Einklang halten

Ein Reset ist nur korrekt, wenn Selektionsindex und Appearance-State dem Wert folgen, also schließt ResetLoadedFormField mit denselben zwei Reconcilern wie SetFormFieldValue ab. HPDFReconcileChoiceSelection nimmt jetzt einen Array-Wert an: Es löscht das lokale /I, ohne es zu mutieren, matcht jeden Wert gegen die Export-Hälfte jedes /Opt-Eintrags und schreibt ein neues sortiertes /I, ein Reset auf [(b) (r)] gegen die Optionen b, g, r liefert also /I [0 2]. ReconcileLoadedButtonAppearanceStates fragt jetzt nach dem geerbten Typ, also bekommt ein Kind-Checkbox, dessen /FT /Btn auf dem Parent wohnt, endlich sein /AS gesetzt. Auf der Schreibseite speichern SetFormFieldValue und SetLoadedFormFieldDefaultValue ein Name-Objekt für einen geerbten Nicht-Pushbutton-Button, selbst wenn das Kind keinen lokalen Eintrag hat, von dem es den Typ kopieren könnte. Und wenn EnsureLoadedFieldAppearanceStream Button-Appearances neu baut, schreibt es /AS /Off, sofern der Wert nicht dem On-State entspricht, und versieht jeden State-Stream mit ordentlichem /Type /XObject, /Subtype /Form und /BBox; vor v2.754.4 konnte das Neu-Generieren der Appearance nach einem Reset die Box wieder ankreuzen, bevor die Datei gespeichert war

Grenzen, die man kennen sollte, bevor man darauf baut

Die skalaren Getter bleiben skalar. GetFormFieldValue und GetLoadedFormFieldDefaultValue geben für einen Array-Wert einen leeren String zurück, stringifizieren Zahlen und Booleans als 42 oder true und melden einen hex-kodierten String in seiner hexadezimalen Schreibweise. Ein /Parent-Zyklus beendet den Walk ohne Exception, ein Feld, dessen Typ in einem Zyklus verloren geht, meldet also lfftUnknown und Flags von 0, statt zu scheitern. SetFormFieldValue und ResetLoadedFormField schreiben immer das Kind, das Sie adressieren, und stufen nie einen Wert zum geteilten Parent hoch, was für unabhängige Kinder richtig ist, aber bedeutet, dass Radio-Gruppen über das Feld adressiert werden sollten, das die Selektion besitzt. Und jeder Aufruf committet ein Feld für sich allein; nichts davon macht eine Batch von Resets transaktional

Die hier beschriebene Auflösung geerbter Attribute, die vereinheitlichte Field-Tree-Klassifizierung und der typisierte Reset sind Teil der Loaded-Form-API in der HotPDF Delphi Component für Delphi und C++Builder, neben der unter AcroForm-Felder zu einer geladenen PDF in Delphi hinzufügen abgedeckten Felderstellung