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