Technischer Artikel

Atomares Batch-Füllen von PDF-Formularen mit PDFium

PDFium Component for Delphi füllt ein AcroForm atomar mit TPdf.SetFormFieldValues: Du übergibst 1 bis 256 typisierte Requests, adressiert nach Seite und Widget, und die Komponente wendet entweder jeden Wert in einer einzigen Dokumentersetzung an oder keinen. Ist ein Request ungültig, benennt TPdfFormValuesWriteReport.FailedRequest den Request, der den Batch versenkt hat

Diese Garantie zählt am meisten in den Jobs, die niemand beobachtet. Ein nächtlicher Import schiebt CRM-Zeilen in ein Schadensformular, oder ein Druckdienst legt Policendaten über eine vorgedruckte Vorlage. Fahre die Feld für Feld ab, und der erste schlechte Wert, ein Code, der kein gültiger Radio-Export ist, ein String länger als das MaxLen des Felds, lässt dich mit einem PDF zurück, halb alte Daten, halb neu. Nichts ist abgestürzt, also hat niemand gewarnt. Die Batch-API existiert, damit ein Fehler eine saubere Verweigerung ist statt eines halb geschriebenen Dokuments

Warum ist feldweises Formularfüllen für den Datenimport gefährlich?

Feldweises Füllen ist gefährlich, weil jede Schreibung für sich committet, also hinterlässt ein Fehler mitten in der Schleife ein Dokument, das weder zum Quelldatensatz noch zur Originalvorlage passt. Das klassische Muster ist eine Schleife über FormField[Index]-Zuweisungen, jeder Durchlauf durch PDFiums native Form-Fill-Umgebung, ein Widget je Mal. Wert 1 bis 17 landen, Wert 18 wirft, und der Aufrufer muss jetzt exakt wissen, welche Werte hineingingen, um sie zurückzunehmen

Die meisten Aufrufer schreiben jenen Rücknahme-Code nie, und die, die es tun, machen ihn meist subtil falsch: Ein geteiltes Feld hat sich auf jeder Seite, auf der es auftaucht, bereits geändert, und Checkbox-Zustände sind bereits gekippt. Läuft das Sichern in einem finally-Block, wandert die halb gefüllte Datei auf die Platte, komplett aussehend. Bei einem Überdruck-Job ist das schlimmer als keine Datei, denn sie wird gedruckt, und jemand unterschreibt sie

Das andere leise Problem ist der Fokus. Der klassische Setter fährt PDFiums interaktiven Editor: Er killt jeden bestehenden Fokus, fokussiert das Ziel-Widget, wählt den ganzen Text, ersetzt ihn und killt den Fokus erneut. Für einen Headless-Job ist das fein, aber in einem Viewer beendet es auch jede Editier-Session, die der Anwender in einem anderen Feld offen hatte. Der typisierte Schreibpfad, gekommen mit PDFium Component 3.231.0, geht gar nicht durch nativen Feldfokus, und 3.233.0 erweiterte ihn von einzelnen Feldern zu seitenübergreifenden Batches

Wie SetFormFieldValues einen Batch in einer Ersetzung anwendet

TPdf.SetFormFieldValues stage-t jeden Request gegen einen einzigen erfassten Snapshot des Dokuments, schreibt alle Feld-Dictionarys in einem Durchgang und tauscht das fertige Ergebnis mit einer einzigen Ersetzung ein. Das Originaldokument bleibt lebendig bis zum allerletzten Schritt, jeder Fehler vor diesem Schritt lässt es unangetastet

Die fünfstufige atomare Batch-Formularfüll-Sequenz im PDFium Component for Delphi, vom Preflight bis zum Verifizieren und Tauschen mit alles-oder-nichts-Ergebnissen
jeder Request wird gegen einen einzigen Snapshot vorbereitet und das lebende Dokument erst beim finalen verifizierten Tausch angefasst

Die Sequenz erklärt sowohl die Garantie als auch den Preis:

  1. Preflight-Prüfungen laufen zuerst: Das Dokument muss aktiv sein, die Request-Zahl muss 1..256 sein, keine Page-Object- oder Edit-Journal-Transaktion darf offen sein, kein natives Feld darf Fokus halten, und das Dokument muss Fill-Forms- oder Modify-Berechtigung gewähren
  2. Jeder Request wird auf Seitenbereich und Input-Budget validiert, und FailedRequest verfolgt den geprüften Index
  3. Die Komponente erfasst einen Edit-Snapshot, normalisiert das AcroForm in jener Kopie und wendet jeden Request der Reihe nach an, schreibt /V, /I für Choice-Auswahlen und /AS für jedes Widget, das das Feld teilt
  4. Die Änderungen werden an den Snapshot als ein dünn besetztes inkrementelles Update im Sinn von ISO 32000-1 §7.5.6 angehängt, also wandern fremde Objekte Byte für Byte mit
  5. Eine separate Verifizierer-Instanz lädt zuerst die Kandidaten-Bytes; erst nachdem sie sauber öffnet, entlädt die Komponente das lebende Dokument und lädt das neue, mit Rückfall auf den Original-Snapshot, falls das Laden scheitert

Der interne Ersetzungsschritt ist dieselbe Maschinerie, die die Komponente für ihre Edit-Journal-Snapshots nutzt, weshalb das Undo- und Crash-Recovery-Journal und eine Batch-Schreibung sich nicht überlappen können: Beende oder brich die Journal-Transaktion zuerst ab. Automatische Lebenszyklus-Aktionen sind unterdrückt, solange das Dokument getauscht wird, ein Dokumentöffnen-Skript kann also keine Felder umschreiben oder Fokus greifen mitten in der Ersetzung

Einen typisierten Request-Batch in Delphi bauen

Ein Batch ist ein dynamisches Array aus TPdfFormValueRequest-Records, jeder hält einen 1-basierten Page, einen 0-basierten seitenlokalen FieldIndex und einen typisierten TPdfFormValue. Der Wert-Record trägt ein Kind aus fvkText, fvkChoice, fvkCheckBox oder fvkRadioButton, plus Text, ChoiceIndices und Checked-Member, verwendet je nach jenem Kind

Importdaten kommen üblicherweise mit Feldnamen als Schlüssel an, also ist die erste Aufgabe, Namen in Seiten- und Widget-Adressen zu übersetzen. FieldIndex zählt Widget-Annotationen auf einer Seite in derselben Ordnung wie FormFieldCount und FormFieldInfo, ein einfacher Scan genügt also. Der Helfer nimmt das erste Widget, das er für einen Namen findet, was für geteilte Felder zählt, wie der nächste Abschnitt erklärt

type
  TFieldAddress = record
    Page, FieldIndex: Integer;
  end;

function FindFieldAddress(Pdf: TPdf; const FieldName: WString;
  out Address: TFieldAddress): Boolean;
var
  P, I: Integer;
begin
  Result := False;
  for P := 1 to Pdf.PageCount do
  begin
    Pdf.PageNumber := P;
    for I := 0 to Pdf.FormFieldCount - 1 do
      if Pdf.FormFieldInfo[I].Name = FieldName then
      begin
        Address.Page := P;
        Address.FieldIndex := I;
        Exit(True);      // nur das erste Widget: ein Request je Feld
      end;
  end;
end;

procedure AddRequest(var Requests: TPdfFormValueRequests;
  const FieldName: WString; Pdf: TPdf; const Value: TPdfFormValue);
var
  Address: TFieldAddress;
  N: Integer;
begin
  if not FindFieldAddress(Pdf, FieldName, Address) then
    raise Exception.CreateFmt('Template has no field "%s"', [FieldName]);
  N := Length(Requests);
  SetLength(Requests, N + 1);
  Requests[N].Page := Address.Page;
  Requests[N].FieldIndex := Address.FieldIndex;
  Requests[N].Value := Value;
end;

Beachte, dass AddRequest bei einem unbekannten Namen wirft, statt ihn zu überspringen. Der Batch validiert Adressen, nicht dein Mapping, ein fehlendes Vorlagenfeld muss also hier scheitern oder scheitert nie. Mit aufgelösten Adressen ist das Bauen der Werte und das Committen kurz

var
  Pdf: TPdf;
  Requests: TPdfFormValueRequests;
  Report: TPdfFormValuesWriteReport;
  V: TPdfFormValue;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'claim-template.pdf';
    Pdf.Active := True;

    V := Default(TPdfFormValue);
    V.Kind := fvkText;
    V.Text := 'Maria Lopez';
    AddRequest(Requests, 'Claimant.Name', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkCheckBox;
    V.Checked := True;
    AddRequest(Requests, 'Consent', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkRadioButton;
    V.Text := 'Express';                // Exportwert des zu wählenden Buttons
    AddRequest(Requests, 'Shipping', Pdf, V);

    V := Default(TPdfFormValue);
    V.Kind := fvkChoice;
    SetLength(V.ChoiceIndices, 1);
    V.ChoiceIndices[0] := 2;            // 0-basierter Index ins Opt-Array des Felds
    AddRequest(Requests, 'Region', Pdf, V);

    if Pdf.SetFormFieldValues(Requests, Report) then
      Pdf.SaveAs('claim-filled.pdf')
    else
      LogBatchFailure(Requests, Report);
  finally
    Pdf.Free;
  end;
end;

Die typisierten Kinds werden gegen den Feldtyp geprüft, bevor irgendetwas geschrieben wird. Ein Textwert auf eine Checkbox gerichtet, ein Radiowert auf eine schlichte Checkbox gerichtet oder eine Schreibung auf einen Push-Button: alles weist ab. Choice-Indizes müssen gültig, eindeutig und aufsteigend sein; mehr als ein Index verlangt das MultiSelect-Flag, und freier Text in einem Choice-Feld wird nur von einer editierbaren Combo-Box akzeptiert. Ein Textwert länger als das MaxLen des Felds weist ab, statt gekürzt zu werden, und readonly-Felder weisen rundweg ab. Bei Radiogruppen mit dem NoToggleToOff-Flag weist auch ein leerer Exportwert ab, gemeint ist "die Gruppe leeren"

Warum scheitert der ganze Batch, wenn ein geteiltes Feld zweimal geschrieben wird?

TPdf.SetFormFieldValues weist zwei Requests ab, die auf dasselbe Terminal-Feld auflösen, denn beide würden einen einzigen Wert schreiben, und die Komponente verweigert es, einen Request stillschweigend einen anderen überschreiben zu lassen. Die Prüfung läuft auf dem Feldbesitzer, nicht dem Widget, zwei verschiedene Widgets eines Felds auf zwei verschiedenen Seiten zählen also weiterhin als Duplikat

Der Grund ist das Feldmodell in ISO 32000-1 §12.7.3.1. Ein Terminal-Feld besitzt den Wert; seine Widget-Annotationen sind nur Orte, an denen jener Wert angezeigt wird. Ein Feld namens PolicyNumber, das im Kopfbereich jeder Seite auftaucht, ist ein Feld mit mehreren Widgets, und jedes dieser Widgets zeigt dasselbe /V. Eine Radiogruppe funktioniert genauso: Jeder Button ist ein Widget, die Gruppe ist das Feld. PolicyNumber durch das Widget auf Seite 1 zu schreiben und erneut durch das Widget auf Seite 9 ist ein Feld, zweimal geschrieben, und jede Regel, die einen Gewinner kürt, versteckt einen Bug in deinem Mapping

Der Batch behandelt es also als Fehler und benennt den zweiten Request in FailedRequest. In der Praxis liegt die Lösung upstream: Löse jeden Feldnamen auf exakt eine Adresse auf, wie FindFieldAddress es tut, und lass die Komponente den Wert auf die anderen Widgets ausbreiten. Das tut sie als Teil der Schreibung, sie setzt /AS auf jedem Widget einer geteilten Checkbox oder Radiogruppe, sodass der Appearance-Zustand zum Feldwert passt, dieselbe Aufteilung, behandelt im Artikel zu Checkbox-Feldwerten versus Widget-Zuständen

Ein Terminal-Feld besitzt den Wert, Widgets auf zwei Seiten zeigen ihn, und ein doppelter Request scheitert den Batch im PDFium Component for Delphi
ein Request je Terminal-Feld, die Komponente schreibt /V einmal und setzt /AS auf jedem Widget, das es teilt

Den Fehlerreport lesen

Ein gescheiterter Batch liefert False, wirft nicht und lässt das Originaldokument und jeden Feldwert exakt, wie sie waren. TPdfFormValuesWriteReport meldet dann Succeeded = False, null für sowohl UpdatedFields als auch UpdatedWidgets, den 0-basierten Index des betroffenen Requests in FailedRequest und einen menschenlesbaren Grund in ErrorMessage

procedure LogBatchFailure(const Requests: TPdfFormValueRequests;
  const Report: TPdfFormValuesWriteReport);
var
  R: Integer;
begin
  R := Report.FailedRequest;
  if R >= 0 then
    Log(Format('Request %d (page %d, widget %d) rejected: %s',
      [R, Requests[R].Page, Requests[R].FieldIndex, Report.ErrorMessage]))
  else
    Log('Batch rejected as a whole: ' + Report.ErrorMessage);
end;

Ein FailedRequest von -1 heißt, die Verweigerung gehört zur Transaktion statt zu einem Request: Nativer Fokus war aktiv, das Dokument ist signiert, das Formular ist XFA, oder eine Berechtigungsprüfung schlug fehl. Ein nicht-negativer Wert zeigt auf einen Eintrag. Bei einem Budget-Überlauf des Aggregats zeigt er auf den Request, der den Batch über das Limit schob, was nicht unbedingt der falsche Request ist. Halte ein paralleles Array von Quellspaltennamen neben Requests, und du kannst Index 14 direkt in "Zeile 2087, Spalte ShipDate" im Import-Log verwandeln

Bei Erfolg gilt UpdatedFields gleich der Request-Zahl, und UpdatedWidgets zählt jedes angefasste Widget, geteilte eingeschlossen, eine billige Plausibilitätsprüfung gegen die Vorlage

Vorbedingungen und Grenzen, an die du laufen wirst

Die häufigste Verweigerung in einer Viewer-Anwendung ist der native Fokus: Tippt ein Anwender in ein Feld, verweigert SetFormFieldValues, bevor es überhaupt den Snapshot erfasst, und der ausstehende Editor-Buffer überlebt intakt. Die Komponente wirft den Fokus nicht hinter deinem Rücken weg, denn das würde entweder committen oder Text verlieren, mit dem der Anwender nicht fertig ist. Rufe ClearFormFieldFocus bewusst auf, was die Editor-Session beendet, so wie ein Klick außerhalb des Felds, und wiederhole dann

Die Budgets sind fest und dokumentiert, du kannst Importe also daran bemessen:

  • 1 bis 256 Requests je Batch; splitte größere Datensätze in mehrere Batches und akzeptiere, dass die Atomizität dann je Batch hält
  • Höchstens 262.144 UTF-16-Einheiten Text und 4.096 Choice-Indizes je Request
  • Höchstens 1.048.576 UTF-16-Einheiten Text und 16.384 Choice-Indizes über den ganzen Batch
  • Die üblichen Dokumenten-Budgets für den Snapshot-Parser gelten weiterhin, inklusive 64 MiB Quelle und 65.536 Objekte

Eine erfolgreiche Ersetzung invalidiert außerdem native Handles, Suchen und Selektionen, gewonnen vor dem Aufruf, denn das Dokument darunter ist neu. Seiten- und View-Aktivierung sowie logische Feld-Cursor bleiben erhalten; alles andere, was du gecacht hast, etwa Annotations- oder Page-Object-Handles, muss neu geholt werden

Woran unterscheidet sich das vom XFDF-Import?

TPdf.ImportXFDF und TPdf.SetFormFieldValues bringen beide Daten in Felder, aber der XFDF-Import ist ein toleranter Merge, während der Batch eine strenge Transaktion ist. Der XFDF-Import matcht Werte auf Felder nach Name, überspringt Namen, die er nicht findet, ignoriert Duplikate und liefert, wie viele Einträge er angewendet hat. Genau richtig für das Verschmelzen von Prüfer-Kommentaren, wo ein Teilergebnis weiterhin nützlich ist

Der Batch macht auf jedem Punkt das Gegenteil. Er adressiert Widgets explizit, weist unbekannte oder falsch typisierte Ziele ab, behandelt Duplikate als Fehler und hat kein teilweises Ergebnis. Nimm XFDF, wenn die Daten eine Austauschdatei aus einem anderen Werkzeug sind; nimm SetFormFieldValues, wenn dein eigener Code das Mapping besitzt und ein halb angewandter Datensatz ein Defekt wäre

Entscheidungsmatrix, die den XFDF-Import mit dem atomaren SetFormFieldValues-Batch im PDFium Component for Delphi vergleicht: Zieladressierung, unbekannte und doppelte Ziele, Ergebnis und beste Passung
XFDF verschmilzt Austauschdateien kulang, während der Batch unbekannte Namen, Duplikate und Teilergebnisse verweigert

Was die Batch-Schreibung nicht abdeckt

Der Batch ist ein begrenztes AcroForm-Profil, und er sagt nein, statt außerhalb dieses Profils zu raten. XFA-Formulare weisen ab und brauchen ihren dedizierten Editor. Signierte Dokumente weisen ab, denn Feld-Dictionarys unter einer bestehenden Signatur umzuschreiben ist ein Signatur-Workflow, keine Füll-Operation; verschlüsselte Snapshots liegen ebenso außerhalb des Profils. Missgestaltete oder mehrdeutige Button-Export-Arrays weisen ab, und Radio-Exportwerte, die auf mehr als einen Button passen, werden verweigert, sofern die Gruppe nicht RadiosInUnison setzt. Die Transaktion normalisiert außerdem direkte Feld-Dictionarys vor dem Schreiben, der Fall hinter Feldwerten, die zurückzukehren scheinen

Die Optiken verdienen einen Satz Sorgfalt. Bei geänderten Text- und Choice-Widgets wirft der Batch das veraltete /AP weg, statt eine Optik auszuliefern, die den alten Wert zeigt, und er schaltet NeedAppearances aus, damit PDFium nicht jedes fremde Widget im Formular neu baut. PDFium regeneriert die fehlenden Optiken, wenn es rendert. Geht das gefüllte PDF an eine strenge Druck-Pipeline, die nie Optiken synthetisiert, rendere oder flache es zuerst durch PDFium und teste diesen Pfad mit deinen eigenen Vorlagen

SetFormFieldValues und die typisierten Wert-Records kommen im PDFium Component for Delphi and Lazarus, wo die Produktseite die volle Form-Fill- und AcroForm-API neben Rendering-, Editing- und Viewer-Controls listet