Technischer Artikel

Warum XFA-Feldbearbeitungen beim Speichern in PDFium für Delphi verschwinden

TPdf.SetFocusedFormFieldText in der PDFium-Komponente schreibt in den lebenden Bearbeitungspuffer des aktuell fokussierten Formularfelds, und bei einem XFA-Formular erreicht dieser Puffer nie das datasets-Paket, das auf die Festplatte serialisiert wird — sodass ein Wert, den ein Benutzer eintippt und den Ihr Code als akzeptiert bestätigt, beim nächsten Öffnen der Datei still verschwunden ist. AcroForm-Felder haben dieses Problem nicht: derselbe Aufruf committet in den /V-Eintrag des Feldes, sobald der Fokus wandert. Ein Benutzer, der ein XFA-Aufnahmeformular ausfüllt, speichert und es erneut öffnet, um das Betragsfeld wieder leer vorzufinden, trifft nicht auf einen Rendering-Glitch — er trifft auf den Rand dessen, was die PDFium-Engine selbst zum Schreiben von Formulardaten freilegt

Das ist eine engere Frage als das Erkennen eines XFA-Formulars überhaupt, oder das Zum-Laufen-Bringen seines JavaScripts: nicht "unterstützt PDFium XFA" und nicht "wie führe ich AcroForm-Skripte aus", sondern konkret, was mit einem Wert passiert, nachdem SetFocusedFormFieldText Erfolg meldet. Die Kurzfassung ist, dass AcroForm und XFA, soweit es PDFiums Schreibpfad betrifft, keine zwei Dialekte desselben Formularmodells sind — es sind zwei Formularmodelle mit zwei völlig unterschiedlichen Beziehungen zwischen dem, was ein Benutzer eintippt, und dem, was ein Speichern tatsächlich erfasst, und die zwei zu vermischen ist das, was einen Ein-Zeilen-API-Aufruf drei Wochen nach dem Livegang eines Kunden-Pilotprojekts zu einem Support-Ticket macht. Der AcroForm-JavaScript-Artikel zeigt den Ein-Zeilen-Aufruf und nennt das AcroForm-versus-XFA-Ergebnis in einem Code-Kommentar; dieser bleibt bei derselben API und geht den internen Schreibpfad durch, den Datasets-Paket-Beweis, dass das XFA-Schreiben nie landet, warum die Lücke in PDFium selbst sitzt statt in der Delphi-Bindung, und einen Patch-Ihr-eigenes-XML-Workaround für Dokumente, bei denen die Bearbeitung ein Speichern überleben muss

Wie schreibt SetFocusedFormFieldText einen Feldwert?

TPdf.SetFocusedFormFieldText funktioniert, indem es eine Bearbeitung auf Tastenanschlag-Ebene simuliert, nicht indem es einen Wert direkt in das Dokumentmodell hineinstupst. Intern ruft es FORM_SelectAllText auf, um den aktuellen Inhalt des fokussierten Felds auszuwählen, dann FORM_ReplaceSelection, um die Auswahl mit dem neuen String zu überschreiben — dieselben zwei Operationen, die ein tastaturgetriebenes Alles-auswählen-und-eintippen auslösen würde. Weil das Schreiben über PDFiums interaktiven Textbearbeitungspfad läuft statt daran vorbei, feuert jedes an das Feld gebundene Keystroke-, Format- oder Calculate-Skript genau so, wie es das für einen tippenden Menschen täte, was die API für programmatisches Formularausfüllen in einem Viewer nützlich macht, der JavaScript aktiv hält. Das Gegenstück auf der Leseseite ist FocusedFormFieldText, gestützt von FORM_GetFocusedText, und es spiegelt denselben lebenden Puffer, den SetFocusedFormFieldText gerade geschrieben hat

if Pdf.FocusedFormFieldIndex >= 0 then
begin
  if Pdf.SetFocusedFormFieldText('1284.50') then
    Log('Buffer now reads: ' + Pdf.FocusedFormFieldText)
  else
    Log('No field is focused, or it does not accept text');
end
else
  Log('Focus a field first - FocusFormField or a real click');

Warum behält AcroForm den Wert und XFA verliert ihn?

AcroForm-Text- und Combo-Felder bleiben bestehen, weil PDFiums eigene Form-Fill-Umgebung den Bearbeitungspuffer für Sie committet: in dem Moment, in dem das Feld den Fokus verliert, wird der Puffer in den /V-Eintrag des Felds geschrieben, denselben Schlüssel, den jeder konforme PDF-Reader ansieht, um den gespeicherten Wert eines Felds zu kennen. TPdf.ClearFormFieldFocus — das unter der Haube FORM_ForceToKillFocus aufruft — erzwingt diesen Commit auf Anfrage, sodass Code, der programmatisch einen Wert setzt, nicht auf einen echten Mausklick anderswo in der UI warten muss. Speichern Sie unmittelbar danach, ist der neue Text Teil des Dokumentobjektgraphen, bevor TPdf.SaveAs überhaupt läuft, weil /V ein echter Eintrag in einem echten Feld-Wörterbuch ist, nichts, das nachträglich angeflanscht wird

Pdf.FocusFormField(FieldIndex);
Pdf.SetFocusedFormFieldText('1284.50');
Pdf.ClearFormFieldFocus;              // forces the /V commit now
Pdf.SaveAs('invoice-acroform.pdf');

// Reopen and confirm - this is an AcroForm document, so it holds
Pdf.Active := False;
Pdf.FileName := 'invoice-acroform.pdf';
Pdf.Active := True;
Pdf.FocusFormField(FieldIndex);
Assert(Pdf.FocusedFormFieldValue = '1284.50');   // passes

Wo lebt eine XFA-Feldbearbeitung tatsächlich?

XFA-Felder haben keine solche Verdrahtung. Der Text, den ein Benutzer eintippt, landet in einem CPWL_Edit-Puffer, der PDFiums XFA-Render- und Interaktionsschicht gehört, und diese Schicht hat keinen Codepfad, der den Puffer zurück in das im PDF gespeicherte datasets-Paket kopiert. TPdf.GetXfaDatasets macht die Lücke sichtbar: rufen Sie es vor und nach einer Bearbeitung eines XFA-Felds auf, und die zurückgegebenen Bytes sind identisch, weil die Methode das ursprüngliche Paket liest, mit dem das Dokument geöffnet wurde, nie den lebenden Zustand des gerade bearbeiteten Widgets. Nichts davon ist ein Caching-Bug oder ein Refresh-Timing-Problem — das Datasets-Paket auf der Festplatte und der Bearbeitungspuffer im Speicher sind schlicht zwei unterschiedliche Zustandsstücke, die PDFiums öffentliche API nie verbindet

var
  Before, After: TBytes;
begin
  Before := Pdf.GetXfaDatasets;
  Pdf.FocusFormField(FieldIndex);
  Pdf.SetFocusedFormFieldText('1284.50');
  After := Pdf.GetXfaDatasets;
  // Before and After are byte-for-byte identical on an XFA document -
  // the edit never touched the packet GetXfaDatasets reads from
end;

Ist das ein Bug in der PDFium-Komponente oder eine Einschränkung von PDFium?

Das fehlende Stück sitzt in PDFium selbst, nicht in der Delphi-Bindung darüber. PDFiums öffentliche API hat kein FPDF_SetXFAPacket, um ein aktualisiertes Paket zu injizieren, und kein FPDF_SaveAsXFA, um die XFA-Engine zu bitten, ihr aktuelles DOM vor einem Speichern zurück in datasets-XML zu serialisieren. FPDF_SaveAsCopy — der Export, der TPdf.SaveAs stützt — schreibt den Dokumentobjektgraphen aus, den PDFium bereits hat; es hat keinen Haken, um die XFA-Engine zu bitten, zuerst ihren lebenden Zustand zu leeren, weil dieser Haken stromaufwärts nicht existiert. Die PDFium-Komponente kann keine Abstimmung hinzufügen, die PDFium selbst nie implementiert hat, und einen selbstgebauten DOM-zu-XML-Serialisierer auszuliefern, der PDFiums internen XFA-Zustand errät, wäre schlimmer als die ehrliche Lücke: Es würde so aussehen, als würde es funktionieren, bis die nächste PDFium-Version etwas ändert, das niemand außerhalb des Projekts sehen kann

Diese Grenze tauchte während desselben v2.13.2-Audits auf, der SetFocusedFormFieldText überhaupt erst baute. FORM_ReplaceSelection war für Versionen in der DLL-Import-Tabelle gebunden gewesen, ohne je aus Pascal-Code aufgerufen zu werden, und den Schreibpfad hinzuzufügen, der es endlich verwendete, ist das, was die Persistenz-Lücke konkret genug machte, um sie zu dokumentieren statt nur theoretisch zu bleiben. Dieselbe Audit-Runde deckte eine unabhängige, aber im Geiste verwandte Lücke auf: AcroForm-JavaScript war seit v2.13.0 still deaktiviert gewesen, weil die JS-Plattform nur innerhalb des XFA-Initialisierungszweigs verdrahtet war, sodass gewöhnliche AcroForm-Dokumente mit app.alert oder berechneten Feldern überhaupt keine Skript-Engine bekamen. Diese war behebbar — die JS-Plattform auf jedes Dokument unabhängig von XFA zu erweitern — und wurde in derselben Version ausgeliefert; die hier behandelte Persistenz-Lücke war aus den obigen Gründen nicht behebbar. Die JavaScript-Lösung und die Host-Veto-Ereignisse darum werden in dem Ausführen von AcroForm-JavaScript mit der PDFium-Komponente behandelt

Was sollten Sie in Delphi dagegen tun?

Bei AcroForm-Dokumenten ist die Lösung nichts weiter als eine gute Gewohnheit: ClearFormFieldFocus aufrufen (oder den Fokus anderweitig wegbewegen), bevor SaveAs läuft, wann immer ein Wert programmatisch gesetzt wurde, statt anzunehmen, eine spätere UI-Interaktion würde den Commit für Sie auslösen. Bei einem Dokument, das entweder AcroForm oder XFA sein könnte — der übliche Fall in einem Allzweck-Viewer — prüfen Sie FormType oder den booleschen Wert XFA, bevor Sie einem Aufrufer versprechen, dass ein Speichern hält, und lesen Sie das Erkennen von XFA-Formularen und Extrahieren von XFA-Paketen für den vollständigen Satz an Prüfungen, einschließlich des XFAF-Falls, bei dem XFA-Inhalt über ansonsten gewöhnlichen AcroForm-Widgets geschichtet ist, die /V tatsächlich respektieren

Bei einem echten dynamischen XFA-Formular, bei dem die bearbeiteten Werte ein Speichern überleben müssen, ist der interaktive Bearbeitungspuffer überhaupt nicht das richtige Werkzeug. Der dauerhafte Weg besteht darin, GetXfaDatasets als Ihre Baseline zu behandeln, nicht als Ihr Ergebnis: Lesen Sie es einmal beim Öffnen des Dokuments, führen Sie Ihre eigene Aufzeichnung dessen, was der Benutzer Feld für Feld geändert hat — genau die Werte, die Ihre UI bereits hat, da PDFium sie Ihnen im Nachhinein nicht zurückgibt —, flicken Sie das selbst in die Baseline-XML, und steuern Sie Ihre eigene Ausgabe. Ein Schreiben, das über XML läuft, das Ihr eigener Code kontrolliert, übersteht ein Speichern, das ein CPWL_Edit-Puffer nie könnte

function ExportEditedXfaValue(Pdf: TPdf; const FieldPath,
  NewValue: string): TBytes;
var
  DatasetsXml: string;
begin
  // GetXfaDatasets ships with PDFium Component; PatchXmlNode below is
  // your own helper over your own XML library, nothing PDFium provides
  DatasetsXml := TEncoding.UTF8.GetString(Pdf.GetXfaDatasets);
  DatasetsXml := PatchXmlNode(DatasetsXml, FieldPath, NewValue);
  Result := TEncoding.UTF8.GetBytes(DatasetsXml);
end;

Die Lücke fangen, bevor ein Kunde es tut

TPdf.SaveAs gibt True zurück, unabhängig davon, ob ein XFA-Feldwert überlebt hat, weil das Speichern aus PDFiums Sicht tatsächlich erfolgreich war — es hat jedes Byte geschrieben, um das es gebeten wurde. Das macht dies genau zu der Art von Defekt, der an einem Smoke-Test vorbeirutscht und einen Kunden erreicht: nichts wirft eine Exception, nichts protokolliert, die Datei öffnet problemlos, nur der spezifische Wert ist falsch. Ein Round-Trip-Test, der die gespeicherte Datei tatsächlich erneut öffnet und den Wert des Felds vergleicht — oder GetXfaDatasets vor und nach vergleicht, gemäß dem früheren Beispiel — gehört in die Regressions-Testsuite jedes Viewers, der Benutzer XFA-Inhalt bearbeiten lässt, nicht nur die AcroForm-Pfade, die standardmäßig zufällig funktionieren

Nichts davon ist so sehr ein Defekt, den man gegen die PDFium-Komponente einreichen sollte, sondern eher eine Grenze, um die herum man designen sollte: SetFocusedFormFieldText tut genau das, was sein Name für beide Formularmodelle sagt, und der Unterschied im Ergebnis lässt sich sauber darauf zurückführen, womit AcroForm und XFA diesen Puffer jeweils auf der PDFium-Seite verdrahten. Die hier referenzierte API, die Fokus- und Speicher-Primitiven sowie die Paket-Reader sind Teil der PDFium-Komponente für Delphi und C++Builder