Technischer Artikel

Formularfeldwerte in einem geladenen PDF mit Delphi setzen

In der Feldliste fast jedes PDF-Viewers heißt das Straßenfeld auf einem deutschsprachigen Formular Straße. Die Bytes hinter seinem /T-Eintrag erzählen eine andere Geschichte: <FEFF005300740072006100DF0065>, ein hexadezimaler UTF-16BE-String mit einem Byte-Order-Mark davor. Code, der Feldnamen gegen diese rohen Bytes matcht, meldet das Feld als fehlend, obwohl ein Nutzer direkt hineinklicken kann. Dasselbe Formular kann den On-Zustand einer Checkbox als 2 statt als Yes speichern und seine Länderliste als [export display]-Paare, was Ihnen drei getrennte Wege liefert, wie der Wert, den Sie schreiben, und der Wert, den ein Viewer malt, auseinandergehen

Alle drei Varianten kommen aus derselben Quelle: ein Formular, das vor Jahren in dem Autorensystem gebaut wurde, das der Kunde gerade hatte, eines, das Ihre Delphi-Anwendung nun aus einer Datenbank befüllen und als Datei zurückgeben muss, die in jedem Reader gleich rendert. Das Formular können Sie nicht neu bauen, also muss der Befüllungscode den Regeln folgen, nach denen es geschrieben wurde. ISO 32000-1 hat für jeden der drei Fälle eine Regel: §7.9.2.2 für Text-Strings, §12.7.4.2.3 für Button-Appearance-Zustände, Tabelle 231 in §12.7.4.4 für Choice-Auswahlen. Felder zu erzeugen, die noch nicht existieren, ist eine andere Aufgabe, behandelt in AcroForm-Felder zu einem geladenen PDF in Delphi hinzufügen

Das Feld finden: HotPDF dekodiert /T, bevor es Namen matcht

THotPDF.SetFormFieldValue hat zwei Überladungen, eine mit nullbasiertem Feldindex und eine mit vollqualifiziertem Namen; beide lösen gegen eine Namenstabelle auf, die die Komponente bei der ersten Nutzung aus dem AcroForm-Baum aufbaut. Während diese Tabelle gebaut wird, läuft jeder Teilname durch einen Dekoder. Ein hexadezimaler /T-String wird zurück in Bytes verwandelt; beginnen diese Bytes mit FE FF und haben gerade Länge, wird der Rest als UTF-16BE gelesen und als UTF-8 neu kodiert. Jeder dekodierte Teilname wird dann mit einem Punkt an seinen Elternnamen angehängt, genau wie §12.7.3.1 es beschreibt; ein Kid-Feld namens City unter einem Parent namens Address wird also als Address.City registriert. Nur der Lookup-Key wird dekodiert. Das /T-Objekt in der Datei behält seine Hex-Schreibweise, und das Speichern des Dokuments rührt die Identität eines bloß befüllten Felds nicht an

Keys werden kleingeschrieben, bevor sie in die Tabelle wandern, deshalb findet 'address.city' das Address.City. Das geht über den Standard hinaus, der Feldnamen als case-sensitiv behandelt, und es bedeutet, dass zwei Felder, die sich nur durch Groß-/Kleinschreibung unterscheiden, im Lookup kollidieren würden. Für Code, der Feldnamen aus einer von Hand getippten Konfigurationsdatei liest, ist ein vergessener Großbuchstabe ein viel häufigerer Ausfall als zwei Felder in einem Formular, die sich nur durch die Schreibweise unterscheiden

Wie HotPDF nicht-ASCII-AcroForm-Namen auflöst: der hexadezimale /T-String wird zurück in Bytes verwandelt, das FE-FF-Byte-Order-Mark wird als UTF-16BE dekodiert und als UTF-8 neu kodiert, und der qualifizierte Name hängt sich an seinen Parent, sodass Applicant.FullName und ein Feld namens Straße beide in der Lookup-Tabelle 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

Die dekodierten Namen sind UTF-8-Bytes in einem AnsiString, und genau da kommen Delphis eigene String-Konvertierungen in die Quere. Ein Literal wie 'Applicant.Straße', an einen AnsiString-Parameter übergeben, wird über die System-ANSI-Codepage konvertiert; ß kommt also als das Einzelbyte DF an statt als das UTF-8-Paar C3 9F, und der Lookup geht daneben. ASCII-Namen sind nicht betroffen. Für alles andere: Enumerieren Sie die Felder einmal, dekodieren Sie jeden Namen mit UTF8ToString und schreiben Sie über die Index-Überladung:

// FindFieldIndex ist ein Helper, den Sie selbst ergänzen: er vergleicht die
// dekodierten UTF-8-Namen gegen einen Unicode-String, sodass keine ANSI-Code-
// page zwischen Ihrem Literal und dem Lookup sitzt
function FindFieldIndex(Pdf: THotPDF; const Name: string): Integer;
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldCount - 1 do
    if SameText(UTF8ToString(Pdf.GetFormFieldName(I)), Name) then
      Exit(I);
  Result := -1;
end;

var
  Pdf: THotPDF;
  Street: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('claim-form.pdf') <= 0 then Exit;

    // ASCII-Namen dürfen direkt zur By-Name-Überladung
    Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');

    Street := FindFieldIndex(Pdf, 'Applicant.Straße');
    // Der Wert ist ebenfalls ein AnsiString, ein Literal 'ß' hinge also von der
    // System-Codepage ab; FEFF-Hex (Hauptstraße 12) umgeht das
    if Street >= 0 then
      Pdf.SetFormFieldValue(Street,
        'FEFF00480061007500700074007300740072006100DF0065002000310032');

    // Dasselbe FEFF-präfixierte UTF-16BE-Hex funktioniert mit der By-Name-
    // Überladung (dieses hier ist München); es wird als Hex-String geschrieben
    Pdf.SetFormFieldValue('Applicant.City', 'FEFF004D00FC006E006300680065006E');

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

Was SetFormFieldValue schreibt, Schritt für Schritt

Beide Überladungen laufen dieselbe Sequenz ab, nachdem das Feld gefunden ist: /V schreiben, den Choice-Auswahlindex neu bauen, das Feld als geändert markieren, Button-Appearance-Zustände umschalten, die Text- oder Choice-Appearance regenerieren und das Feld in einer Dirty-Menge vermerken. Dieser letzte Schritt speist das parameterlose RecalculateLoadedFormFieldsIncremental, das nur die Berechnungsskripte erneut ausführt, die ein geändertes Feld lesen, direkt oder über eine Kette wie Position, Zwischensumme, Gesamtsumme

Der Objekttyp des neuen /V hängt vom Feld ab. Für eine Checkbox oder einen Radio-Button schreibt SetFormFieldValue immer ein Name-Objekt, denn §12.7.4.2.3 speichert den gewählten Zustand als Namen, und PDF-Namen sind schlichte Bytes ohne Text-String-Kodierung. Jedes andere Feld bekommt ein String-Objekt, und der übergebene Wert wird zuerst inspiziert. Ein String, der mit FEFF beginnt, gerade Länge hat und nur Hex-Ziffern enthält, gilt als die UTF-16BE-Form aus §7.9.2.2 und wird als <FEFF...> serialisiert. Darauf verlässt sich die City-Zeile oben. Alles andere wird als Literal-String mit exakt den Bytes gespeichert, die Sie geliefert haben; simpler Lateintext geht also als simpler Text hinein

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

Bei einem Button-Feld entscheidet der Wert nicht allein, was gezeichnet wird. Ein Checkbox-Widget trägt einen /AS-Eintrag, der benennt, welcher Stream in /AP /N gerade zu sehen ist, und Viewer malen aus /AS, nicht aus /V. Ändern Sie /V auf Yes, während /AS noch Off sagt, widerspricht sich die Datei selbst; ein Flattener bäckt die ungecheckte Box in die Seite ein, während die Formulardaten behaupten, sie sei gecheckt. SetFormFieldValue besucht deshalb das Button-Feld und jeden Eintrag in seinem /Kids-Array, liest den On-State-Namen aus dem /AP /N jedes Widgets und setzt /AS auf diesen Namen, wenn er zum Feldwert passt, sonst auf Off

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 SetFormFieldValue 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 Wert, den sie über die /Parent-Kette vom Elternfeld erben, also schaltet das Setzen der Gruppe auf einen Exportwert exakt dieses Widget an und jedes Geschwister aus

Echte Formulare biegen diese Regel auf zwei Arten. Die Off-Appearance heißt im Standard Off, doch Autorensysteme lassen ihren Stream oft komplett weg und überlassen dem Viewer das Nichts-Zeichnen; ein Normal-Appearance-Dictionary mit einem einzigen Eintrag ist also völlig legal. Der On-State-Name ist, was der Autor gewählt hat: Yes, On, 2, ein Wort in der Sprache des Formulars. Der On-State gilt deshalb als der erste Key in /AP /N, der nicht Off ist, und der Vergleich mit Ihrem Wert ignoriert die Schreibweise. Radio-Buttons kommen mit der Anordnung aus §12.7.4.2.4: Der gewählte Wert lebt in /V am Elternfeld, während jedes Kid-Widget meist kein eigenes /V hat. Jedes Kid wird gegen den Wert verglichen, den es über seine /Parent-Kette erbt

// Checkbox: Der Wert muss dem On-State-Key in /AP /N entsprechen
// (hier 'Yes'; der Formulardesigner mag '2' oder 'On' gewählt haben)
Pdf.SetFormFieldValue('Consent', 'Yes');

// Radio-Gruppe: /V geht als Name an den Parent; jedes Kid-Widget
// bekommt /AS auf seinen eigenen On-State-Namen oder auf Off gesetzt
Pdf.SetFormFieldValue('PaymentMethod', 'Card');

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

Die Kehrseite: Ein Tippfehler erzeugt keine Exception. 'yes ' mit Leerzeichen am Ende matcht nichts, die Box endet also auf Off, und die Datei bleibt perfekt gültig. Kommen Ihre Werte aus einer Datenbankspalte, trimmen Sie sie vor dem Aufruf

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

Eine Combo-Box oder List-Box hält ihre Auswahl an zwei Orten fest. Neben /V definiert Tabelle 231 /I als Array nullbasierter Indizes in /Opt, und ein Viewer, der /I auf Option 0 zeigen sieht, während /V Option 3 nennt, hebt unter Umständen die falsche Zeile hervor. SetFormFieldValue baut /I bei jedem Aufruf eines Choice-Felds neu. Es löscht den eigenen /I-Eintrag des Felds, bevor irgendetwas anderes passiert, und editiert das alte Array niemals in place: Ein indirektes Array kann mit einem anderen Feld geteilt sein, und es zu verändern, würde auch dessen Auswahl ändern. Danach löst es /Opt über die /Parent-Kette auf, denn Optionen können geerbt sein, und läuft die Einträge ab. Eine nackte String-Option wird direkt verglichen, ein [export display]-Paar an seinem Export-Element, und ein Paar mit weniger als zwei Elementen wird übersprungen

Wie HotPDF ein Choice-Feld konsistent hält: SetFormFieldValue 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 als dekodierten Text, 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

Der Vergleich läuft auf dekodiertem Text, sodass eine in PDFDocEncoding gespeicherte Option zu einem als UTF-16BE-Hex geschriebenen Wert passt und umgekehrt. Ein skalarer Wert erzeugt höchstens einen Index; welche Mehrfachauswahl das Feld vorher hielt, wird ersetzt. Trifft nichts zu, wird überhaupt kein /I geschrieben. Für eine editierbare Combo-Box ist das das richtige Ergebnis, denn §12.7.4.4 lässt den Nutzer einen Wert außerhalb der Liste tippen, und ein solcher Wert hat keinen Index, auf den er zeigen könnte

Dieselbe Regel steckt hinter der häufigsten Beschwerde über Combo-Boxen: Der Aufruf gelingt, /V hält den neuen Text, und der Viewer zeigt nichts ausgewählt. Meist hat der Aufrufer das Display-Label (Canada) an eine Liste aus [CA Canada]-Paaren übergeben. Die Komponente kann das nicht als Fehler behandeln, ohne editierbare Combos zu brechen; der Check gehört also in Ihren Code. GetFormFieldOptionValue liefert das Export-Element jeder Option, ein Guard wird damit kurz:

// SetChoiceByExport ist Ihr eigener Guard, nicht Teil der Komponente:
// er weist Werte ab, die auf kein Export-Element in /Opt passen
procedure SetChoiceByExport(Pdf: THotPDF; const FieldName,
  ExportValue: AnsiString);
var
  I: Integer;
begin
  for I := 0 to Pdf.GetFormFieldOptionCount(FieldName) - 1 do
    if Pdf.GetFormFieldOptionValue(FieldName, I) = ExportValue then
    begin
      // /Opt ist [[US United States] [CA Canada] [MX Mexico]]:
      // 'CA' passt zum Export-Element, und /I wird [1]
      Pdf.SetFormFieldValue(FieldName, ExportValue);
      Exit;
    end;
  raise Exception.CreateFmt('%s is not an export value of %s',
    [string(ExportValue), string(FieldName)]);
end;

// Editierbare Combo mit freiem Text: direkt schreiben. /V wird gesetzt,
// /I wird entfernt, und kein Index wird erfunden
Pdf.SetFormFieldValue('Title', 'Principal Engineer');

Zeigt sich der neue Wert ohne /NeedAppearances?

Ja, bei Text- und Choice-Feldern. Ein Viewer malt den gespeicherten /AP-Stream und schaut nicht auf /V; ein neuer Wert mit alter Appearance lässt also den alten Text auf dem Schirm und im Druck stehen, sofern das AcroForm-Dictionary nicht /NeedAppearances true (§12.7.3.3) trägt und der Viewer sich entschließt, es zu ehren. Flattener und Thumbnail-Generatoren ignorieren dieses Flag häufig. SetFormFieldValue regeneriert den Appearance-Stream für Text- und Choice-Felder als Teil des Aufrufs, mit demselben Builder wie das öffentliche EnsureLoadedFieldAppearanceStream. Der Stream wird aus dem geerbten /DA-String, der /Q-Ausrichtung, /MaxLen für Comb-Felder und dem Wert selbst gebaut. Der in /DA genannte Font wird über die AcroForm-/DR-Ressourcen aufgelöst, ein Type0-Font behält also seine eigenen Glyphen, statt auf Helvetica zurückzufallen. Buttons behalten die Appearance-Zustände, die ihr Designer gezeichnet hat; bei ihnen bewegt sich nur /AS

SetFormFieldValue ist eine Prozedur und verrät nicht, ob überhaupt etwas gemalt wurde. Ein Widget ohne brauchbares /Rect bekommt keinen Stream, und kaputte Formulare mit verwaisten oder rechtecklosen Widgets existieren wirklich. Wenn das gerenderte Ergebnis zählt, rufen Sie EnsureLoadedFieldAppearanceStream selbst auf, nachdem Sie den Wert gesetzt haben. Es gibt True zurück, wenn mindestens ein Widget einen Stream erhalten hat. GetFormField reicht Ihnen ein THPDFLoadedFormField weiter, dessen Index-Property der Aufruf erwartet; das Objekt gehört Ihnen und muss freigegeben werden

var
  Field: THPDFLoadedFormField;
begin
  Pdf.SetFormFieldValue('Applicant.FullName', 'Maria Schneider');
  Field := Pdf.GetFormField('Applicant.FullName');
  try
    // False heißt, kein Widget dieses Felds hatte ein Rechteck zum Hineinmalen;
    // der Wert ist gespeichert, aber nichts wird ihn 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;

Zu prüfen, ob sich /V geändert hat, beweist wenig darüber, was ein Nutzer zu sehen bekommt. Ein Test, der etwas bedeutet, rendert die Seite davor und danach und bestätigt, dass sich Pixel innerhalb des Widget-Rechtecks änderten und außerhalb nicht. Diesen Check halten wir uns, weil Appearance-Bytes allein uns schon in die Irre geführt haben: Ein Stream hielt jedes richtige Byte, während ein Viewer, der /NeedAppearances true ehrte, die Anzeige aus /V neu baute und nichts davon zeigte

Werte zurücklesen und was üblicherweise folgt

GetFormFieldValue liefert das geerbte /V des Felds. Ein als FEFF-präfixiertes Hex gespeicherter Wert kommt dekodiert als UTF-8 zurück; das City-Feld von oben liest sich also als die UTF-8-Bytes von München, und UTF8ToString macht einen Delphi-String daraus. Ein Literal-String kommt mit den Bytes zurück, mit denen er gespeichert wurde. Für Round-Trip-Prüfungen: Vergleichen Sie beidseitig dekodierten Text statt der Schreibweise, die Sie übergeben haben

Felddaten in Masse zwischen Formular und einem externen System zu bewegen, statt eines Aufrufs pro Feld, ist das Thema von XFDF-Import und -Export in Delphi. Ein fertiges Formular abschließen, damit niemand es mehr editiert, heißt AcroForm- und XFA-Felder in Delphi flattening, das genau die hier beschriebenen /AS-Zustände und Appearance-Streams in statischen Seiteninhalt verwandelt. Was an diesem Punkt inkonsistent ist, bleibt für immer inkonsistent; also befüllen, prüfen, dann flatten

SetFormFieldValue, EnsureLoadedFieldAppearanceStream und die inkrementelle Neuberechnungs-API sind Teil der HotPDF Delphi Component, neben den Aufrufen, die Felder in einem aus Delphi oder C++Builder geladenen Dokument umbenennen, entfernen und flatten