Technischer Artikel

AcroForm-geerbte Feldwerte und Resets in Delphi

Ein Umfrageformular zeigt in Acrobat zwei Dropdown-Listen, group.a und group.b. Läuft man /AcroForm /Fields mit Code ab, der jeden Knoten mit eigenem /FT für ein fertiges Feld hält, kommt die Zählung als 1 zurück: ein einziges Feld namens group mit zwei Widgets. Fragt man group.b beim Namen, schlägt die Suche fehl. Öffnet man das Dictionary des Kindes, steht darin kein /FT überhaupt, kein /Ff, keine Optionsliste. Nichts ist kaputt. Typ, Flags und Optionen sitzen eine Ebene höher auf dem Parent, und die PDF-Spezifikation erlaubt genau das ausdrücklich

Das Layout ist legal und sparsam. Ein Authoring-Tool schreibt einen Gruppenknoten mit /FT /Ch, /Ff 131078 und einem /Opt-Array einmalig und hängt darunter zwei benannte Kinder, jedes ein verschmolzenes Feld-plus-Widget-Dictionary, das nur /T, /Parent, /Rect und sein eigenes /V hält. So ein Formular korrekt zu lesen verlangt drei Dinge: jedem vererbbaren Schlüssel über /Parent folgen, entscheiden, wo der Feldbaum wirklich endet, und sicherstellen, dass ein Reset einen Wert vom richtigen PDF-Typ schreibt. Dieses Stück macht dort weiter, wo Formularfeldwerte in einer geladenen PDF mit Delphi setzen aufhört

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

ISO 32000-1 §12.7.3.1 (Table 220) markiert /FT, /Ff, /V und /DV als vererbbar, und Table 229 in §12.7.4.3 tut dasselbe für das /MaxLen eines Textfelds. Options-Arrays bekommen hier dieselbe Behandlung, denn Producer, die eine Auswahlliste über Kinder teilen, legen /Opt auf die Gruppe. Ein Reader, der nur das lokale Dictionary prüft, meldet für ein völlig gültiges Kind also den falschen Typ, Flags 0 und einen leeren Wert

HotPDF Delphi Component beantwortet jede dieser Leseoperationen auf dieselbe Art. Es sucht den Schlüssel im eigenen Dictionary des Felds und, wenn er dort steht, löst es die indirekte Referenz auf und hört auf. Sonst folgt es /Parent und versucht es erneut, höchstens 128 Ebenen weit. Diese Deckelung ist keine Dekoration. /Parent-Pointer in beschädigten Dateien können einen Loop bilden, der mit /Kids nichts zu tun hat, und ein Reader, der ihnen bedingungslos vertraut, hängt an der ersten solchen Datei, der er begegnet

Eine Regel in diesem Walk ist leicht verkehrt herum zu verstehen: Das erste Dictionary, das den Schlüssel enthält, gewinnt, auch wenn der dort gespeicherte Wert ein leerer String ist. Ein lokales /V () auf group.b ist der Autor, der sagt "dieses Kind ist leer", und der Walk stoppt dort, statt weiterzuklettern zu einem Wert, den der Parent vielleicht hält. Leer als "fehlend" zu behandeln ließe ein geleertes Kind die Auswahl seines Parent anzeigen — das Gegenteil von dem, was der Autor schrieb

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; die geerbte Attribut-Auflösung läuft /Parent bis zu 128 Ebenen nach oben, das erste Dictionary mit einem Key gewinnt, und ein leerer lokaler Wert maskiert den Parent
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

Die öffentlichen Getter sitzen alle auf dieser Suche: GetFormFieldType, GetFormFieldValue, GetLoadedFormFieldFlags, IsFormFieldRequired, IsFormFieldNoExport, GetLoadedFormFieldMaxLength, GetLoadedFormFieldDefaultValue und die Options-Helfer GetLoadedFormFieldOptionCount und GetLoadedFormFieldOptions. Das Kind der Umfragegruppe zu lesen braucht also keinen Sondercode:

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;

GetFormField liefert bei jedem Aufruf einen neuen THPDFLoadedFormField-Wrapper und wirft bei unbekanntem Namen eine Exception: den Aufruf außerhalb des try-Blocks des Wrappers halten und den Wrapper selbst freigeben

Finden, wo der Feldbaum endet

Ein lokales /FT ist der falsche Test für ein Endfeld, denn ein Parent kann den Typ liefern und trotzdem benannte Kindfelder besitzen. Der verführerische Traverse hört auf abzusteigen, sobald ein Knoten ein eigenes /FT hat oder kein /Kids. Im Umfrageformular hat group beides, /FT /Ch und /Kids, also registriert dieser Traverse es als ein Feld mit zwei Widget-Annotationen. Die Namen group.a und group.b verschwinden. Die Feldzahl ist 1. Eine Suche nach dem Kindnamen schlägt fehl, und ein Wert-Schreiben kann nur auf dem geteilten Parent landen

HotPDF schaut stattdessen auf die Kids. Ein Kid zählt als Kindfeld, wenn es ein eigenes /T hat, ein eigenes /Kids hat oder kein /Subtype /Widget-Dictionary ist. Nur wenn kein Kid durchgeht, ist der Knoten ein Endfeld, und seine Kids werden dann als seine Widgets behandelt. Beide Grenzfälle hinter dieser Regel stammen aus verschmolzenen Dictionarys, die §12.7.3.1 erlaubt, wenn ein Feld genau ein Widget hat:

  • Ein benanntes verschmolzenes Dictionary trägt /Subtype /Widget und bleibt trotzdem ein Kindfeld. Der Subtype allein kann es nicht in die anonyme Widget-Liste des Parent drängen; das /T entscheidet
  • Manche Producer wiederholen das /FT des Parent auf jedem anonymen Widget. /FT auf einem Widget kann also ebenso wenig als "hier beginnt ein neues Feld" gelesen werden

Dieselbe Klassifikation treibt den Feldindex, FormFieldExists und RemoveFormField an. Jeder dieser Walks notiert außerdem die bereits besuchten Dictionarys und gibt nach 128 Ebenen auf. Angenommen, die Gruppe ist Objekt 5 und listet sich zweimal unter ihren eigenen Kids: /Kids [5 0 R 5 0 R 6 0 R 7 0 R]. Sie meldet weiterhin exakt zwei Felder, die Kinder 6 und 7. Keine endlose Rekursion, und kein Phantom-Drittfeld aus der Selbstreferenz

Wie vermeidet RemoveFormField, Geschwisterfelder zu löschen?

RemoveFormField löscht nur das Kind, das man nennt, weil Discovery und Löschung eine einzige Definition eines Endfelds teilen. Der Namens-Overload löst einen Feldindex aus der Feldliste auf, läuft dann /AcroForm /Fields ein zweites Mal ab und zählt Endfelder, um das zu löschende Dictionary zu finden. Würde der zweite Walk die verführerische /FT-Regel nutzen, während der erste die Kid-basierte nutzte, würden die Indizes auseinanderlaufen: Der Lösch-Walk sähe group als Feld 0, und group.a zu entfernen nähme den Parent, das Geschwister und beide Widgets mit

Der Lösch-Walk wendet also denselben Kind-Feld-Test und dieselbe Visited-Menge wie die Discovery an. Er sammelt die Widget-Annotationen, die zum entfernten Kind gehören, streicht nur diese aus dem /Annots jeder Seite und entfernt den Parent erst, wenn sein /Kids-Array leer ausgeht. Solange group.b noch gelistet ist, bleibt der Parent, und Typ, Flags und Optionen, die group.b erbt, bleiben bei ihm

HotPDF-Diagramm zum Geschwister-Überleben bei RemoveFormField: Der Lösch-Walk nutzt denselben Kind-Feld-Test und dieselbe Visited-Menge wie die Discovery, 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');

// Sanity-Checks, die Sie selbst ergänzen, kein Bestandteil der Bibliothek
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');

Wer eine Entfernung in den eigenen Formularen prüft, schaut an drei Stellen nach, denn jede erwischt einen anderen Fehler: das /Kids des Parent, die Seiten-/Annots und Wert und Appearance des überlebenden Geschwisters. Einmal nach SaveLoadedDocument und einmal nach SaveIncrementalUpdate, denn ein inkrementeller Save hängt nur die geänderten Objekte an, und das /Kids des Parent muss darunter sein

Was ResetLoadedFormField aus einem geerbten /DV kopiert

ResetLoadedFormField schreibt ein lokales /V, das eine frische Kopie des geerbten /DV ist, mit demselben PDF-Objekttyp, und es validiert den kompletten Default, bevor es das Feld anfasst. Ein Checkbox-Default ist ein Name wie /Yes. Ein Multi-Select-Listenfeld-Default ist ein Array aus Strings. Ein Text-Default darf ein hexadezimaler UTF-16-String wie <FEFF...> sein. Einen davon durch GetLoadedFormFieldDefaultValue und SetFormFieldValue zu schicken liefert einen String, wo ein Name hingehört, einen leeren String, wo ein Array war, oder literal hexadezimale Ziffern

Der Reset verzweigt deshalb danach, was das geerbte /DV tatsächlich ist:

  • Text- und Choice-Felder mit String-Default bekommen ein neues String-Objekt, das das Hexadecimal-Flag des Originals hält
  • Choice-Felder mit Array-Default bekommen ein neues Array aus neuen Strings; ein Array, das etwas anderes als Strings enthält, wirft eine Exception
  • Checkboxen und Radiobuttons bekommen ein neues Name-Objekt
  • Ein Default jeden anderen Typs wirft eine Exception, und /V und /I bleiben exakt, wie sie waren
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 nehmen den reinen-String-Pfad

Zu kopieren statt zu referenzieren ist bewusst gewählt, und es ist die einzige sichere Option. Ein Kind-/V, das auf das /DV-Array des Parent zeigte, würde ein Objekt zwischen "aktueller Wert" und "Default-Wert" teilen; das nächste SetFormFieldValue auf diesem Kind würde stillschweigend den Default aller Geschwister umschreiben. Ein paar Extra-Bytes pro Reset-Feld sind ein billiger Preis dafür, beide auseinanderzuhalten. Pushbuttons, die keinen Wert haben (Table 226, Bit 17), und Signaturfelder bleiben auf einem schlichten Pfad, der eine String-Kopie ihres eigenen /DV schreibt

Existiert nirgendwo in der Kette ein /DV, schreibt die Methode einen lokalen leeren String, bzw. /Off bei einem Checkbox- oder Radio-Feld. Das /V des Kindes zu löschen sähe aufgeräumter aus und gäbe die falsche Antwort. Hält der Parent einen aktuellen Wert, kommt dieser Wert durch das Entfernen des Kind-Overrides sofort zurück, und das Reset-Feld zeigt etwas, das der Nutzer nie hineingetippt hat

var
  Field: THPDFLoadedFormField;
begin
  // Parent 'palette' ist eine Multi-Select-Liste mit
  // /Opt [(b) (g) (r)] und /DV [(b) (r)]; das Kind hält nur /T und /V
  Field := Pdf.GetFormField('palette.primary');
  try
    try
      Pdf.ResetLoadedFormField(Field.Index);
      // Das Kind hat jetzt sein eigenes /V [(b) (r)] und /I [0 2];
      // das /DV-Array des Parent ist unberührt
    except
      on E: Exception do
        // Fehlerhafter Default (falscher Typ, Nicht-String-Array-Item):
        // /V und /I blieben, wie sie waren
        Writeln('Reset skipped: ', E.Message);
    end;
    // Der skalare Getter kann einen Array-Default nicht darstellen
    Writeln(Pdf.GetLoadedFormFieldDefaultValue(Field.Index)); // leer
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('survey-reset.pdf');
end;

Ein Einzelfeld-Reset ist zudem etwas anderes als die ResetForm-Aktion aus §12.7.5.3, die ein Viewer über eine Menge von Feldern laufen lässt, wenn der Nutzer auf einen Button klickt; diese Aktion ist unter AcroForm-Felder und -Aktionen mit HotPDF bauen beschrieben. ResetLoadedFormField editiert ein geladenes Feld, folgt seiner eigenen Regel für den Fall ohne Default und markiert das Feld als geändert, damit die inkrementelle Neuberechnung es aufnimmt

/V, /I und /AS im Einklang halten

Ein Reset ist nur korrekt, wenn Selektionsindex und Appearance-State dem Wert folgen, und ResetLoadedFormField schließt mit denselben zwei Folgeschritten wie SetFormFieldValue. Bei Choice-Feldern entfernt es das lokale /I des Kindes, 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 ergibt /I [0 2]. Das geerbte /I des Parent bleibt, falls vorhanden, unberührt

Buttons arbeiten mit dem geerbten Typ. Ein Kind-Checkbox, deren /FT /Btn auf dem Parent liegt, bekommt sein /AS trotzdem aktualisiert. 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, aus dem sich der Typ kopieren ließe. 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 /Type /XObject, /Subtype /Form und einem /BBox. Ohne diese /Off-Prüfung würde das Neugenerieren der Appearances nach einem Reset die Box schon vor dem Speichern wieder als angehakt zeigen

Skalare Getter und andere Randfälle, die man einplant

Die String-Getter bleiben skalar, und Code, der ihre Ausgabe vergleicht, sollte wissen, was sie flachdrücken. GetFormFieldValue und GetLoadedFormFieldDefaultValue liefern für einen Array-Wert einen leeren String, machen aus Zahlen und Booleans 42 oder true und melden einen Hex-kodierten String in seiner hexadezimalen Schreibweise. Wer wissen will, ob ein Listenfeld-Default zwei Selektionen hält oder keine, dem kann der Default-Getter es nicht sagen: Beides kommt als leerer String zurück

Ein /Parent-Zyklus beendet die Suche ohne Exception, ein Feld, dessen Typ in einem Zyklus verloren geht, meldet also lfftUnknown und Flags 0. lfftUnknown auf einem benannten Feld als Zeichen einer beschädigten Datei behandeln, die ein Logging wert ist. SetFormFieldValue und ResetLoadedFormField schreiben immer das adressierte Kind und promoten nie einen Wert zum geteilten Parent. Das ist richtig für unabhängige Kinder wie die beiden Umfrage-Dropdowns. Radiogruppen sind anders: Die Selektion gehört dem Feld, das die Gruppe besitzt, also dieses Feld adressieren statt eines seiner Kids

Jeder Aufruf committet außerdem genau ein Feld für sich. Eine Reset-Schleife über zwanzig Felder sind zwanzig unabhängige Edits; wirft der fünfte an einem fehlerhaften Default, sind die ersten vier bereits angewandt. Für Batch-Arbeit: erst Typ und Default jedes Ziels lesen, entscheiden, welche Felder man anfasst, und dann erst mit dem Schreiben beginnen

Geerbte Attribut-Suche, die Kid-basierte Feldbaum-Klassifikation und der typisierte Reset gehören alle zur Loaded-Form-API der HotPDF Delphi Component, die auch Delphi- und C++Builder-Projekte abdeckt, die einem bestehenden Dokument neue Felder hinzufügen müssen, wie in AcroForm-Felder zu einer geladenen PDF in Delphi hinzufügen gezeigt