Technischer Artikel

Formularfeldwerte in einem geladenen PDF mit Delphi setzen

Die HotPDF Delphi Component füllt ein bestehendes AcroForm-Feld auf einem geladenen PDF über THotPDF.SetFormFieldValue, adressiert entweder über einen nullbasierten Feldindex oder über einen vollqualifizierten Feldnamen. Den neuen /V-Eintrag zu schreiben ist der leichte Teil; was den Aufruf auf echten Formularen zuverlässig macht, ist, dass dieselbe Methode drei Stücke von Zustand konsistent hält, die unsichtbar sind, bis sie schiefgehen: die dekodierte Identität des Felds, damit ein nicht-ASCII-Name überhaupt gefunden werden kann, den /AS-Appearance-Zustand an Checkbox- und Radio-Widgets und das /I-Auswahlindex-Array an Choice-Feldern. Der sichtbare Appearance-Stream ist ein separater, expliziter Schritt über EnsureLoadedFieldAppearanceStream

Das Szenario ist das alltäglichste: Ein Kunde schickt Ihnen sein eigenes Formular, eine Steuererklärung, einen Versicherungsanspruch, eine Bestellung, die jemand vor Jahren in Acrobat gebaut hat, und Ihre Delphi-Anwendung muss es aus einer Datenbank befüllen und eine Datei zurückgeben, die überall korrekt aufgeht. Sie haben keine Kontrolle darüber, wie das Formular erstellt wurde. Feldnamen können UTF-16-codiert sein, Checkbox-Exportwerte können 2 statt Yes sein, und Comboboxen können [export display]-Optionspaare nutzen. Jedes dieser Details hat eine Regel in ISO 32000-1, und jede Regel ist etwas, das SetFormFieldValue jetzt für Sie erledigt. Dieser Beitrag handelt davon, was es tut, warum und wo es aufhört. Für das Schwesterproblem, Felder zu erzeugen, die noch nicht existieren, siehe AcroForm-Felder zu einem geladenen PDF in Delphi hinzufügen

Warum findet SetFormFieldValue ein Feld mit nicht-ASCII-Namen nicht?

Vor v2.752.1 lag die Antwort in der Codierung: Das Feld lag in der Datei unter einem hexadezimalen UTF-16BE-Namen, und der Namens-Cache speicherte die Hex-Schreibweise statt des Texts. ISO 32000-1 §12.7.3.1 definiert den partiellen Feldnamen /T als Text-String, und §7.9.2.2 sagt, ein Text-String darf UTF-16BE mit einem führenden FE FF Byte Order Mark sein. Autoring-Werkzeuge serialisieren solche Namen routinemäßig als Hex-Strings gemäß §7.3.4.3, also kommt ein Feld namens Straße als <FEFF005300740072006100DF0065> an. Innerhalb von HotPDF hält THPDFStringObject.Value den rohen hexadezimalen Text, sobald IsHexadecimal gesetzt ist – genau das, was Sie für einen verlustfreien Round Trip des Original-Dictionarys wollen, und genau das, was Sie als Lookup-Key nicht wollen. HPDFLoadedFormTextName trennt die beiden Anliegen. Wird der Beziehungs-Cache gebaut, läuft jeder /T-Wert durch ihn: Ist das String-Objekt hexadezimal, stellt HPDFHexToBytes die Bytefolge wieder her; beginnen die Bytes mit FE FF und haben gerade Länge, wird die Nutzlast als UTF-16BE dekodiert und als UTF-8 neu codiert; das Ergebnis wird dann mit einem Punkt an den Elternnamen angehängt, um den vollqualifizierten Namen zu bilden, den §12.7.3.1 beschreibt, also wird ein Kid namens City unter einem Elternfeld namens Address als Address.City registriert. Der Cache-Key wird auf Kleinschreibung normalisiert, wodurch auch SetFormFieldValue('address.city', ...) gelingt; das ist eine Annehmlichkeit über den Standard hinaus, denn die Spezifikation behandelt Namen als case-sensitiv. Entscheidend ist: Nur der Cache-Key ändert sich. Das /T-Objekt im Feld-Dictionary behält seine hexadezimale Codierung, also schreibt das Speichern des Dokuments die Identität eines Felds nicht um, das Sie bloß befüllt haben

Wie HotPDF nicht-ASCII-AcroForm-Namen auflöst: HPDFHexToBytes stellt die UTF-16BE-Nutzlast hinter einem hexadezimalen /T-String wieder her, das FE-FF-Byte Order Mark wird dekodiert und als UTF-8 neu codiert, und der qualifizierte Name hängt sich an sein Elternteil, sodass Applicant.FullName und ein Feld namens Straße beide im Lookup-Cache landen
Nur der Cache-Key ändert sich: Das Feld-Dictionary behält seine hexadezimale Codierung, Lookups normalisieren auf Kleinschreibung als Annehmlichkeit über den Standard hinaus, und das Speichern des Dokuments schreibt die Identität eines bloß befüllten Felds nie um
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // Qualifizierte Namen werden aus UTF-16BE-/T-Strings dekodiert und
    // mit Punkten verbunden, also lösen verschachtelte und nicht-ASCII-Namen auf
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
    Pdf.SetFormFieldValue('Applicant.Straße', 'Hauptstraße 12');

    // Werte, die nicht Latin-1 sind, reisen als FEFF-präfixiertes UTF-16BE-Hex
    // und werden als PDF-Hex-String geschrieben
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

    Pdf.SaveLoadedDocument('claim-form-filled.pdf');
  finally
    Pdf.Free;
  end;
end;

Was schreibt SetFormFieldValue tatsächlich?

Beide Overloads laufen dieselben fünf Schritte: das Feld-Dictionary finden, /V über HPDFSetDictFormValue schreiben, Choice-Auswahlindizes abgleichen, das Dictionary als dirty markieren, Button-Appearance-Zustände abgleichen und schließlich den Feldindex über NoteLoadedFormFieldDirty vermerken. Dieser letzte Schritt zählt, wenn das Formular Berechnungsskripte trägt, denn das Dirty-Set ist das, was der parameterlose RecalculateLoadedFormFieldsIncremental-Overload konsumiert, um nur die Berechnungen neu laufen zu lassen, die transitiv ein geändertes Feld lesen. HPDFSetDictFormValue selbst ist vorsichtig mit dem Objekttyp, den es ersetzt. Ist das bestehende /V ein Name-Objekt – was Checkbox- und Radio-Felder für ihren Exportwert nutzen –, wird der neue Wert als Name geschrieben, nie als String, denn PDF-Namen sind von der Konstruktion her ASCII-only. Andernfalls schreibt es ein String-Objekt und inspiziert den Wert, den Sie übergeben haben: Ein String, der mit FEFF beginnt, gerade Länge hat und ausschließlich aus Hex-Ziffern besteht, wird als die UTF-16BE-Drahtform aus §7.9.2.2 behandelt und mit gesetztem IsHexadecimal gespeichert, sodass er als <FEFF...> serialisiert statt als Literal (FEFF...). Das ist der Mechanismus, auf den sich die City-Zeile oben stützt; jeder andere String wird als Literal-String mit den Bytes gespeichert, die Sie gegeben haben, für reinen Latin-Text übergeben Sie also reinen Text

Warum behält eine Checkbox ihr altes Häkchen, nachdem sich der Wert ändert?

Weil bei einem Button-Feld allein der Wert nicht entscheidet, was gezeichnet wird. ISO 32000-1 §12.7.4.2.3 schreibt vor, dass ein Checkbox-Widget einen /AS-Appearance-Zustand trägt, der benennt, welcher Stream in /AP /N gerade gezeigt wird, und Viewer malen aus /AS, nicht aus /V. Ändern Sie /V auf Yes, lassen aber /AS auf Off, ist die Datei intern widersprüchlich, und ein Flattening bäckt die veraltete ungecheckte Appearance gerne in die Seite ein, während die Formulardaten gecheckt sagen. ReconcileLoadedButtonAppearanceStates existiert, um diese Lücke zu schließen: Bei einem Feld, dessen /FT Btn ist, besucht es das Feld-Dictionary selbst und jeden Eintrag in seinem /Kids-Array, liest den On-State-Namen aus /AP /N und schreibt /AS auf diesen Namen um, wenn er zum Feldwert passt, oder auf Off, wenn nicht

Warum eine HotPDF-Checkbox ihr altes Häkchen behält, wenn sich nur /V ändert: Viewer malen aus dem /AS-Appearance-Zustand in /AP /N, also besucht ReconcileLoadedButtonAppearanceStates das Feld und jedes Kid, liest den On-State-Namen als ersten Key außer Off und schreibt /AS bei Passung um, sonst auf Off
Radio-Gruppen vergleichen jedes Kid gegen den Elternwert, den InheritedButtonValue durch das Laufen der /Parent-Kette wiedergewinnt, also schaltet das Setzen der Gruppe auf einen Exportwert exakt dieses Widget an und jedes Geschwister aus

Zwei Details aus echten Formularen prägten den Fix in v2.752.3. Erstens darf ein Normal-Appearance-Dictionary nur den On-State enthalten; §12.7.4.2.3 benennt die Off-Appearance Off, aber Autoring-Werkzeuge lassen ihren Stream häufig weg und überlassen dem Viewer, nichts zu zeichnen. Früherer Code sprang aus, wenn das Dictionary weniger als zwei Einträge hielt, also behielten diese Ein-State-Checkboxen stillschweigend ihr altes Häkchen. Die Prüfung ist jetzt schlicht, dass das Dictionary nicht leer ist, und der On-State-Name wird als der erste Key genommen, der nicht Off ist. Zweitens ist der On-State-Name das, was der Autor gewählt hat. Echte Formulare nutzen 2, Yes, On oder ein lokalisiertes Wort, also läuft der Vergleich gegen den tatsächlichen Key, ohne Groß-/Kleinschreibung zu beachten, nie gegen ein hartcodiertes Yes. Radio-Buttons fügen eine weitere Falte hinzu, beschrieben in §12.7.4.2.4: Die Auswahl lebt in /V am Elternfeld, während die einzelnen Kids die Widgets besitzen und typischerweise kein eigenes /V haben. Der verschachtelte InheritedButtonValue-Helfer läuft deshalb die /Parent-Kette hinauf, bis zu 64 Ebenen, bis er einen nicht-leeren Wert findet, also wird jedes Kid gegen den Wert der Gruppe verglichen, zu der es gehört. Das Elternfeld auf den Exportwert eines Kids zu setzen schaltet exakt dieses Kid an und jedes Geschwister aus

// Checkbox: Der Exportwert muss zum On-State-Key in /AP /N passen
// (oft „Yes“, aber echte Formulare nutzen „2“, „On“ oder etwas ganz anderes)
Pdf.SetFormFieldValue('Consent', 'Yes');

// Radio-Gruppe: /V wird auf dem Elternteil geschrieben; jedes Kid-Widget bekommt
// /AS auf seinen eigenen Exportnamen oder auf Off gesetzt
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

// Checkbox leeren: Jeder Wert, der zu keinem On-State passt, ergibt /AS Off
Pdf.SetFormFieldValue('Newsletter', 'Off');

Choice-Felder: /I im Takt mit /V halten

Bei einer Combobox oder Listbox ist /V nicht der einzige Ort, an dem eine Auswahl verzeichnet ist. Tabelle 231 in §12.7.4.4 definiert /I als ein Array nullbasierter Indizes in /Opt, das die gewählten Items identifiziert, und ein Viewer, der /I auf Option 0 zeigen sieht, während /V Option 3 benennt, hebt womöglich die falsche Zeile hervor. Seit v2.754.1 läuft HPDFReconcileChoiceSelection in jedem SetFormFieldValue-Aufruf und baut, wenn das geerbte /FT Ch ist, /I aus dem neuen Wert wieder auf. Die Reihenfolge der Operationen ist bewusst. Der lokale /I-Eintrag wird zuerst gelöscht, ohne seinen Inhalt anzufassen: War das alte Array ein indirektes Objekt, das mit einem anderen Feld geteilt wird, würde es an Ort und Stelle zu verändern die Auswahl des anderen Felds korrumpieren, also wirft die Routine die Referenz weg und legt stattdessen ein frisches direktes Array an. Dann löst sie /Opt über die /Parent-Kette auf, denn Choice-Optionen können geerbt sein, und scannt die Einträge. Eine nackte String-Option wird direkt verglichen; ein [export display]-Paar wird auf seinem Export-Element verglichen, und ein Paar mit weniger als zwei Elementen wird übersprungen. Beide Seiten laufen durch HPDFLoadedFormTextName, also matcht eine Hex-UTF-16-Option einen Hex-UTF-16-Wert, ohne dass Sie sie identisch schreiben müssen. Beim ersten Match wird ein Ein-Element-/I geschrieben, und der Scan stoppt; ein skalarer Wert ersetzt immer jede frühere Mehrfachauswahl, ungeachtet des MultiSelect-Flags

Wie HotPDF ein Choice-Feld konsistent hält: HPDFReconcileChoiceSelection löscht das lokale /I-Array, bevor es es anfasst, löst /Opt über die /Parent-Kette auf, vergleicht die Export-Hälfte jeder Option durch HPDFLoadedFormTextName, schreibt ein Ein-Element-/I beim ersten Match und schreibt nichts, wenn ein editierbarer Combo-Wert keinen Index hat
Eine nackte String-Option wird direkt verglichen und ein Export-Display-Paar auf seinem Export-Element, während ein Wert außerhalb von /Opt korrekt keinen Index hinterlässt — ein veraltetes /I auf der falschen Zeile wäre schlimmer als keins

Passt nichts, wird überhaupt kein /I geschrieben. Das ist das korrekte Ergebnis für eine editierbare Combobox, bei der §12.7.4.4 dem Benutzer erlaubt, einen Wert außerhalb der Optionsliste zu tippen; so ein Wert hat keinen Index, und ein veralteter Index wäre schlimmer als keiner. Dasselbe bekommen Sie, wenn Sie einer gepaarten Optionsliste ein Display-Label statt eines Exportwerts übergeben, also prüfen Sie, welche Hälfte des Paars Sie geliefert haben, wenn eine Combobox sich weigert, Ihre Auswahl zu zeigen

// /Opt ist [[US United States] [CA Canada] [MX Mexico]]:
// matche auf dem Exportwert, und /I wird [1]
Pdf.SetFormFieldValue('Country', 'CA');

// Editierbare Combo mit einem Wert außerhalb von /Opt: /V wird geschrieben,
// /I wird entfernt, und kein Index wird fabriziert
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Wert und Appearance sind zwei getrennte Operationen

SetFormFieldValue fasst den Appearance-Stream eines Text- oder Choice-Felds nie an. Nach dem Aufruf hält /V den neuen Text, während /AP /N weiterhin den alten malt, und welche der beiden ein Viewer zeigt, hängt davon ab, ob das AcroForm-Dictionary /NeedAppearances true gemäß §12.7.3.3 trägt und ob der Viewer es ehrt. Wenn die Datei den neuen Wert in jedem Reader rendern soll, eingeschlossen Flattener und Thumbnail-Generatoren, die den Flag ignorieren, rufen Sie EnsureLoadedFieldAppearanceStream mit dem Feldindex auf. Es baut ein Form XObject aus dem geerbten /DA-String, dem /Q-Quadding, dem /MaxLen-Comb-Layout und dem Wert, löst den benannten Font über die AcroForm-/DR-Ressourcen auf, sodass ein Type0-Font seinen eigenen Descendant-Font behält statt zu Helvetica zu verkommen, und gibt True zurück, wenn mindestens ein Widget einen Stream erhalten hat. Der By-Name-Overload von SetFormFieldValue gibt Ihnen keinen Index zurück, also holen Sie einen über GetFormField, das ein THPDFLoadedFormField zurückgibt, das Sie besitzen und freigeben müssen. Die Regression-Suite für die v2.752.1-Änderung ist bezüglich dieser Trennung explizit: Sie setzt einen Wert, ruft EnsureLoadedFieldAppearanceStream auf, rendert dann die Seite und prüft, dass sich die Pixel innerhalb des Widget-Rechtecks geändert haben, während die Pixel außerhalb sich nicht geändert haben. Zu verifizieren, dass /V sich geändert hat, beweist nichts darüber, was ein Benutzer sehen wird

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // Den neuen Wert in /AP malen, damit Viewer, die
    // /NeedAppearances ignorieren, ihn trotzdem zeigen
    if not Pdf.EnsureLoadedFieldAppearanceStream(Field.Index) then
      raise Exception.Create('No widget rectangle to paint into');
  finally
    Field.Free;
  end;
  Pdf.SaveLoadedDocument('claim-form-filled.pdf');
end;

Grenzen, die es zu kennen gilt, bevor Sie darauf aufbauen

ReconcileLoadedButtonAppearanceStates prüft das lokale /FT des Dictionarys, das Sie adressiert haben, also wirkt es auf das Radio-Elternteil oder auf eine Checkbox, die ihr eigenes /FT trägt; ein Kid-Widget, das für sich adressiert wird, mit /FT nur am Elternteil, wird über diesen Pfad nicht abgeglichen. HPDFReconcileChoiceSelection behandelt einen einzelnen skalaren Wert und schreibt höchstens einen Index; Mehrfachauswahl-Listboxen mit mehreren gewählten Einträgen liegen außerhalb dessen, was SetFormFieldValue modelliert. Keine der beiden Routinen validiert den Wert, den Sie übergeben, gegen /Opt oder gegen die On-State-Keys, also produziert ein Tippfehler eine Off-Checkbox oder eine indexlose Combo statt einer Exception. Und GetFormFieldValue gibt den gespeicherten /V-Text zurück, wie er im Dictionary steht, was bei einem Hex-codierten Wert die hexadezimale Schreibweise bedeutet, nicht den dekodierten Text

Sind die Werte drin und die Appearances gemalt, sitzen die zwei natürlichen nächsten Schritte zu beiden Seiten dieser Operation. Formulardaten im Bulk mit externen Systemen auszutauschen, statt einen SetFormFieldValue-Aufruf nach dem anderen, ist das Thema von XFDF-Import und -Export in Delphi. Und wenn das befüllte Formular final ist und nicht mehr editierbar sein soll, bäckt AcroForm- und XFA-Felder in Delphi flatten exakt die hier beschriebenen /AS-Zustände und Appearance-Streams in statischen Seiteninhalt ein, weshalb es zwingend ist, sie vor dem Flattening konsistent zu bekommen

Die API zum Bearbeiten geladener Formulare in diesem Artikel, eingeschlossen SetFormFieldValue, EnsureLoadedFieldAppearanceStream und den inkrementellen Neuberechnungsgraphen, kommt als Teil der HotPDF Delphi Component für Delphi und C++Builder daher