Technischer Artikel

PDFium Component XFA Save: Newlines, Emoji und restoreState

PDFium Component sichert editierte XFA-Formularwerte exakt, über Sichern und Wiederöffnen hinweg, wenn es die Windows-V8-Runtime pdfium.v8.dll ab v3.125.2 fährt. Ältere Runtimes fügten Feldwerten Linefeeds hinzu, kappten Emoji auf ein unverwandtes BMP-Zeichen, übersprungen stillschweigend Single-Stream-XFA-Saves und konnten einen fehlgeschlagenen letzten Schreibvorgang verschlucken. Ein Wiederöffnen-Symptom ist gar kein Bibliotheksdefekt: Ein dynamisches Formular, dessen Root-Subform restoreState="auto" fehlt, baut sein Layout aus der Vorlage neu auf

Die Bug-Reports dazu sahen alle gleich aus. Ein Kunde füllt ein XFA-Formular in einem Delphi-Viewer aus, sichert, öffnet wieder, und irgendwas sitzt leicht schief. Eine leere Kommentare-Box hält jetzt eine Leerzeile, nach einem zweiten Sichern zwei. Ein Name mit einem Emoji kommt mit einem Private-Use-Glyph zurück. Niemand kriegt einen Fehler, und genau das macht diese Bugs teuer: Das Driften taucht Wochen später im Export von jemand anderem auf

Was geht schief, wenn ein XFA-Formular gesichert und wieder geöffnet wird?

Vier separate Defekte im nativen XFA-Save-Pfad verursachten das Wertedriften, und jeder versteckte sich hinter einem erfolgreich aussehenden Sichern. Zwei kamen aus der Serialisierung, einer aus dem Single-Stream-Speicherlayout, und einer aus dem PDF-Writer selbst. Die Tabelle mappt jedes Symptom auf seine Ursache und auf das Release, in dem PDFium Component es gefixt hat

Symptom nach dem WiederöffnenUrsacheGefixt in
Ein leeres Feld hält einen Linefeed; Werte wachsen pro Sichern um einen ZeilenumbruchBeide XFA-Writer fügten Layout-Zeilenumbrüche nach Start-Tags einv3.125.2, pdfium.v8.dll
U+1F642 kommt als U+F642 zurück, oder das Emoji verschwindet aus dem Form-Packet16-Bit-wchar_t-Abschneiden beim Dekodieren; Surrogate-Filterung im Form-Serializerv3.125.2, pdfium.v8.dll
Edits in einem Single-Stream-XFA-Dokument sind schlicht wegDas native Sichern wies das Stream-Layout zurück, aber der Rückgabewert wurde ignoriertv3.125.2; Kommentare und Processing Instructions bleiben seit v3.126.0 erhalten
Abgeschnittene Datei, obwohl das Sichern Erfolg meldeteDer letzte gepufferte Schreibvorgang scheiterte, nachdem der Writer bereits Erfolg gemeldet hattev3.125.2 V8-Runtime; v3.125.3 gewöhnliche pdfium.dll
Ein dreiseitiges dynamisches Formular öffnet als zweiseitiges wiederDie Root-Subform verlangt kein restoreState="auto"Formular-Authoring, kein Bibliotheksdefekt

Frühere Abhandlungen schlossen, XFA-Feldedits ließen sich mit PDFium überhaupt nicht persistieren, was für die Runtimes der damaligen Zeit stimmte. Die neuere V8-Runtime sichert XFA-Werte nativ, ein im Live-Formular gemachter Edit erreicht also das gespeicherte Datasets-Packet, ohne dass Sie selbst am Packet operieren müssen

Welche PDFium-Runtime sichert XFA-Werte?

Die XFA-Save-Fidelity hängt an der nativen DLL, nicht am Delphi-Wrapper, die erste Prüfung lautet also, welche Runtime Ihr Prozess tatsächlich geladen hat. PDFium Component liefert pro Architektur zwei Windows-Builds: die gewöhnliche pdfium.dll, gebaut ohne V8 und XFA, und pdfium.v8.dll, die die JavaScript-Engine und die XFA-Formular-Runtime trägt. Nur pdfium.v8.dll kann ein XFA-Formular fahren, jeder hier beschriebene XFA-Fix wohnt also dort, beginnend mit den neu gebauten Win32- und Win64-V8-Bibliotheken in v3.125.2

Der Final-Write-Fix ist generischer PDF-Writer-Code, er zählt also auch für gewöhnliche Dokumente. v3.125.3 baute die gewöhnlichen pdfium.dll-Bibliotheken neu, um dieselbe Reparatur zu tragen. Geteilter Quellcode ist kein Beweis für geteiltes Verhalten: Bis die Binärdatei neu gebaut ist, behält die alte DLL den alten Bug

Eine zweite Falle saß im Loader. Vor v3.125.2 brachte das Setzen von EnableV8Engine auf True die Bindung dazu, den Default-Namen pdfium.v8.dll zu nehmen und einen vollständigen Pfad in LibraryName zu ignorieren. Eine Anwendung, die auf eine frisch ausgelieferte Runtime zeigte, konnte also weiter eine ältere Kopie aus einem anderen Ordner laden. Seit v3.125.2 wählt ein LibraryName mit Verzeichnis in beiden Engine-Modi exakt diese Datei, und ein fehlender Pfad schlägt fehl, statt auf eine andere gebündelte Bibliothek zurückzufallen

uses
  System.SysUtils, PDFium;

procedure SelectXfaRuntime;
begin
  // Ein Verzeichnis in LibraryName nagelt genau diese Datei fest (v3.125.2 und später);
  // fehlt die Datei, wirft das Laden eine Exception, statt zurückzufallen
{$IFDEF WIN64}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win64\pdfium.v8.dll';
{$ELSE}
  PDFium.LibraryName := ExtractFilePath(ParamStr(0)) + 'DLLs\Win32\pdfium.v8.dll';
{$ENDIF}
  PDFium.EnableV8Engine := True;
  PDFium.LoadLibrary;  // beim Start scheitern, nicht beim ersten Sichern
end;

Nach dem Öffnen eines Dokuments sagt Ihnen TPdf.XFA, dass die Datei XFA enthält, und TPdf.XfaRuntimeAvailable, dass die geladene DLL sie tatsächlich ausführen kann. Müssen Sie außerdem statische und dynamische Formulare auseinanderhalten, liefert TPdf.FormType ftXfaFull oder ftXfaForeground; der Artikel zu dem Erkennen von XFA-Formularen und dem Extrahieren von XFA-Packets in Delphi behandelt dieses Sondieren im Detail

Warum kommen gesicherte XFA-Felder mit zusätzlichen Linefeeds zurück?

Gesicherte XFA-Felder gewannen Linefeeds, weil beide nativen XFA-Writer, der generische XML-Element-Writer und der Form-Packet-Serializer, ihre Ausgabe hübsch druckten, mit einem Zeilenumbruch nach Start-Tags. In den meisten XML-Dateien ist dieser Whitespace kosmetisch. In XFA-Daten ist er es nicht: Wird das Datasets-Packet erneut geparst, ist der Text zwischen <Comments> und </Comments> der Feldwert, Linefeed eingeschlossen. Ein leeres Feld öffnete sich also wieder mit einem einzelnen LF, und jeder weitere Save-and-Reopen-Zyklus konnte einen weiteren ergänzen

PDFium-Component-XFA-Save-Zyklus-Diagramm, in dem der Writer nach Start-Tags einen Zeilenumbruch ergänzt, der wieder geöffnete Parser den LF zwischen den Comments-Tags als Feldwert liest, und jeder weitere Save einen weiteren Linefeed anhängt, bis v3.125.2 nur den vom Serializer synthetisierten Whitespace entfernt
Ein Save-Reopen-Zyklus pflanzt den ersten Linefeed, und jede weitere Runde ergänzt einen, deshalb zeigte das Driften seine volle Gestalt erst in der zweiten Generation

Die naheliegende Reparatur, Werte beim Laden zu trimmen, wäre falsch. Nutzer tippen führende Leerzeichen, abschließende Leerzeichen und absichtlichen Mehrzeilen-Text in XFA-Felder, und ein Adressblock oder ein Festbreiten-Code muss Byte für Byte überleben. Der v3.125.2-Fix entfernt deshalb nur den Whitespace, den der Serializer selbst um Tags herum synthetisiert hat. Nutzwerden, bestehende Textknoten und CDATA-Sektionen laufen unangetastet durch, " indented" bleibt also eingerückt, und ein absichtlich leeres Feld bleibt leer

Warum kommt ein Emoji als anderes Zeichen zurück?

Ein Emoji kam falsch zurück, weil Windows-wchar_t 16 Bits breit ist und zwei Dekodierpfade einen vollständigen Unicode-Skalarwert in einem einzelnen wchar_t speicherten. Der UTF-8-Stream-Decoder und der Parser für numerische Zeichenreferenzen wie &#x1F642; taten das beide. U+1F642, das leicht lächelnde Gesicht, passt nicht in 16 Bits, die hohen Bits fielen also ab, und U+F642 erschien stattdessen: ein Codepunkt in der Private Use Area, den die meisten Fonts als Kasten oder gar nichts rendern

Der Form-Serializer hatte das umgekehrte Problem. Er filterte Zeichen je ein wchar_t auf einmal, sah zwei Surrogate-Code-Einheiten, die für sich genommen ungültig sind, und warf beide weg, das Emoji verschwand also komplett aus dem Form-Packet. In v3.125.2 konsumiert der Decoder jeden Skalarwert vollständig und emittiert ein ordentliches Surrogate-Paar. Bleibt nur noch ein Ausgabe-Slot, hält er die Low-Surrogate zurück und meldet kein Stream-Ende, solange diese Einheit noch gepuffert ist. Eine UTF-8-Sequenz, die über Leseblöcke hinweg gespalten ist, wird in den nächsten Lesevorgang übertragen statt weggeworfen. Der Form-Exporter hält gültige Surrogate-Paare jetzt zusammen, und numerische Zeichenreferenzen produzieren ebenfalls korrekte Paare

PDFium-Component-Diagramm der Surrogate-Behandlung, in dem U+1F642 als UTF-16-Paar D83D DE42 ankommt und zwei Defektpfade es korrumpieren: 16-Bit-wchar_t-Decoder schneiden den Skalar auf U+F642 in der Private Use Area ab, während der Form-Serializer einzelne Surrogate filtert und das Emoji komplett verwirft
Windows-wchar_t ist 16 Bits breit, ein Skalar, der ein Surrogate-Paar braucht, verlor also entweder seine hohe Hälfte oder verschwand aus dem Packet, bis beide Pfade lernten, Paare zusammenzuhalten

Latein-1-Testdaten zeigen davon nie etwas, jeder XFA-Roundtrip-Test braucht also mindestens ein Supplemental-Plane-Zeichen

Single-Stream-XFA und Save-Fehler, die niemand sah

Ein Single-Stream-XFA-Dokument verlor seine Edits, weil der native Save-Helfer dieses Speicherlayout zurückwies und sein Aufrufer den Fehlschlag ignorierte. ISO 32000-1 §12.7.8 erlaubt dem /XFA-Eintrag des interaktiven Formular-Dictionarys entweder ein Array aus Packet-Namen und Streams oder einen einzelnen Stream, der das ganze XDP-Dokument hält. Packet-Arrays sind der gewöhnliche Fall, aber Single Streams sind völlig legal, und das PDF-Sichern schloss ab, als wäre nichts gewesen, während die Formulardaten auf ihren alten Werten blieben

Seit v3.125.2 handhabt die V8-Runtime die unterstützte Single-Stream-Teilmenge. Sie exportiert zuerst beide Live-Packets, datasets und form, in einen Staging-Bereich und validiert sie, und erst dann ersetzt sie die passenden Packets im ursprünglichen XDP. Andere Packets und die Root-Namespace-Deklarationen bleiben erhalten. Scheitert das Staging, wird der persistente XFA-Stream nie angefasst, und das Dokument behält seine Änderungsmarkierung

XML-Kommentare und Processing Instructions brauchten Extra-Pflege, weil das interne XML-DOM sie fallen lässt. In v3.125.2 ließ ihre Anwesenheit das Sichern rundherum scheitern, statt stillschweigend Inhalt zu verlieren. v3.126.0 bewahrt sie: Vor dem Parsen wird jeder Kommentar oder jede Processing Instruction gegen einen Marker getauscht, gebaut aus einem Präfix, das nirgendwo im Originaltext vorkommt. Nachdem die Live-Packets ersetzt sind, muss jeder Marker exakt einmal auftauchen, bevor das Original-Token wiederhergestellt und der Stream geschrieben wird. Tokens außerhalb der ersetzten Packets behalten also ihren Text und ihre Reihenfolge, Tokens im Prolog, im Template und in anderen Packets eingeschlossen

Manche Eingaben werden weiterhin mit Absicht abgewiesen, und jede Ablehnung ist ein expliziter Save-Fehler:

  • Kommentare oder Processing Instructions innerhalb der Live-Packets datasets oder form, denn ihre ursprünglichen Positionen lassen sich nicht in frisch exportierten Inhalt übertragen
  • DTD-Deklarationen und XMLDSig-Signaturen, denn das Umschreiben des XDP kann eine XML-Signatur nicht gültig halten
  • Ungültiges UTF-8 oder UTF-16, unvollständige Tags, ungültige Zeichenreferenzen, unbekannte Entities und malformed Processing Instructions, die abgewiesen statt stillschweigend repariert werden
PDFium-Component-Pipeline des Single-Stream-XFA-Saves, in dem die Live-Packets datasets und form zum Staging exportiert, validiert und dann innerhalb des ursprünglichen XDP ersetzt werden, mit durch Marker bewahrten Kommentaren, während Staging-Fehlschläge und Eingaben wie DTDs oder XMLDSig das Sichern explizit verweigern
Der gestagte Export wird validiert, bevor irgendetwas ersetzt wird, ein gescheitertes Sichern lässt den persistenten XFA-Stream also unangetastet, und das Dokument behält seine Änderungsmarkierung

Die Single-Stream-Ausgabe ist UTF-8 und bewahrt das XML-Inhaltsmodell, nicht das ursprüngliche Byte-Layout oder die Encoding-Deklaration

Der letzte Defekt saß unterhalb von XFA. Der native Datei-Writer puffert die Ausgabe in 32-KB-Blöcken und flushte den letzten Teilblock erst in seinem Destruktor, nachdem der Dokument-Writer bereits Erfolg gemeldet hatte. Ein Disk-full- oder I/O-Fehler auf diesem letzten Block blieb für den Aufrufer unsichtbar. Seit v3.125.2 in der V8-Runtime und v3.125.3 in der gewöhnlichen Runtime gehört dieser finale Flush zum Save-Ergebnis, und die XFA-Änderungsmarkierung wird erst nach einem echten Erfolg geräumt. Auf Delphi-Seite schreibt TPdf.SaveAs(const FileName: string; Option: TSaveOption = saNone; PdfVersion: TPdfVersion = pvUnknown): Boolean in eine temporäre Datei neben dem Ziel und verschiebt sie nur dann an ihren Platz, wenn das Sichern True liefert, ein gescheitertes Sichern lässt also die vorherige Datei intakt

Warum öffnet ein dynamisches XFA-Formular mit weniger Seiten wieder?

Ein dynamisches XFA-Formular öffnet mit weniger Seiten wieder, wenn seine Root-Subform kein restoreState="auto" deklariert, und das ist eine Formular-Authoring-Entscheidung, kein PDFium-Component-Defekt. In XFA 3.3 defaultet restoreState auf der Root-Subform zu manual. Unter manual stellt der XFA-Prozessor nur begrenzten Zustand aus dem gespeicherten Form-Packet wieder her und überlässt den Rest den Skripten des Autors. Gespeicherte Feldwerte und Instanzzahlen wiederholter Subforms kommen weiterhin zurück, aber geometrische Properties, die zur Laufzeit gesetzt wurden, nicht

Der Fall, der das enthüllte, war ein dreiseitiges Formular, dessen Skript eine Subform auf h="450pt" aufblähte. Das gespeicherte Form-Packet hielt die neue Höhe, die Werte und die Instanzzahlen. Beim Wiederöffnen aber wurde das Layout aus den Vorlagen-Höhen neu gebaut, und das Formular floss auf zwei Seiten um. Die Runtime hatte recht: Die Vorlage hatte nie um automatische Wiederherstellung gebeten. Sie auf der Root-Subform zu deklarieren fixt das Wiederöffnen:

<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
  <subform name="form1" layout="tb" restoreState="auto">
    <pageSet>
      <pageArea name="Page1">
        <contentArea x="0.25in" y="0.25in" w="8in" h="10.5in"/>
        <medium stock="letter"/>
      </pageArea>
    </pageSet>
    <subform name="Details" layout="tb" w="7.5in">
      <!-- Felder; Skripte dürfen h ändern oder Instanzen zur Laufzeit ergänzen -->
    </subform>
  </subform>
</template>

Gehört Ihnen die Vorlage nicht, patchen Sie nicht im Viewer drumherum: Ein Formular, das auf den manual-Modus baut, erwartet, dass seine eigenen Skripte den Zustand neu aufbauen. Live-Repagination während der Nutzer tippt ist ein eigenes Thema, behandelt in wie PDFium Component dynamische XFA-Seitenzahlen und umgezogene Felder verfolgt

Wie verifiziert man ein XFA-Sichern in Delphi?

Die einzige verlässliche XFA-Save-Prüfung ist, die gesicherte Datei in einer frischen TPdf-Instanz wieder zu öffnen und die gespeicherten Daten zurückzulesen. TPdf.GetXfaDatasets liefert das Datasets-Packet so, wie es im Dokument gespeichert ist, nicht das Live-XFA-Datenmodell, ein Aufruf vor dem Sichern zeigt also die alten Werte. Nach dem Wiederöffnen zeigt es exakt das Geschriebene. Ein Single-Stream-Dokument hat keine separat benannten Packets: PDFium meldet das ganze XDP als ein Packet mit leerem Namen, GetXfaPacketByName('datasets') und GetXfaDatasets liefern also nichts, und der Fallback liest den kompletten Stream über GetXfaFormPackets

uses
  System.SysUtils, PDFium, FPdfXfa;

function ReadSavedXfaData(const FileName: string): string;
var
  Pdf: TPdf;
  Packets: TXfaPacketList;
  Bytes: TBytes;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    Bytes := Pdf.GetXfaDatasets;          // Packet-Array-Layout
    if Length(Bytes) = 0 then
    begin
      Packets := Pdf.GetXfaFormPackets;   // Single Stream: ein unbenanntes Packet
      if Length(Packets) = 1 then
      begin
        SetLength(Bytes, Length(Packets[0].Content));
        if Length(Bytes) > 0 then
          Move(Packets[0].Content[0], Bytes[0], Length(Bytes));
      end;
    end;
    Result := TEncoding.UTF8.GetString(Bytes);  // Die gespeicherte XDP-Ausgabe ist UTF-8
  finally
    Pdf.Free;
  end;
end;

Die Save-Routine committet dann den ausstehenden Edit, prüft das SaveAs-Ergebnis und vergleicht den wieder geöffneten Wert. TPdf.ClearFormFieldFocus killt den Formularfokus, das ist der Moment, in dem PDFium den Edit-Buffer des fokussierten Felds committet. TPdf.SetFocusedFormFieldText(const Value: WString): Boolean füllt das fokussierte Feld programmatisch, beruht aber auf einem Fokus, den der Wrapper über FocusFormField verfolgt, das Widget-Annotationen abläuft. Eine dynamische XFA-Seite hat normalerweise keine, der Text kommt dort meist über Tastatureingabe in TPdfView, und die Funktion liefert False, wenn kein verfolgtes Feld den Fokus hat

function XmlText(const S: string): string;
begin
  Result := StringReplace(S, '&', '&amp;', [rfReplaceAll]);
  Result := StringReplace(Result, '<', '&lt;', [rfReplaceAll]);
end;

procedure SaveXfaAndVerify(Pdf: TPdf; const FileName, FieldTag,
  Expected: string);
var
  Saved: string;
begin
  // Optionales Skript-Fill; False heißt, kein verfolgtes Feld hat Fokus
  if (Pdf.FocusedFormFieldIndex >= 0) and
     not Pdf.SetFocusedFormFieldText(Expected) then
    raise EPdfError.Create('Could not write the focused field');

  Pdf.ClearFormFieldFocus;              // den Edit-Buffer committen
  if not Pdf.SaveAs(FileName) then      // einschließlich des finalen Flushs (v3.125.2+)
    raise EPdfError.CreateFmt('Saving %s failed', [FileName]);

  Saved := ReadSavedXfaData(FileName);
  if Pos('<' + FieldTag + '>' + XmlText(Expected) + '</' + FieldTag + '>',
    Saved) = 0 then
    raise EPdfError.CreateFmt('%s did not survive the round trip', [FieldTag]);
end;

Behandeln Sie den Teilstring-Test als Smoke-Test. Ein leeres Element kann als <Tag/> serialisiert werden, Attribute können an Datenelementen auftauchen, und Escaping über & und < hinaus ist eine Serializer-Entscheidung. Für Produktions-Prüfungen laden Sie das wieder geöffnete XML mit einem echten XML-Parser und vergleichen den Textknoten des gebundenen Datenelements. Fahren Sie die Prüfung außerdem zweimal hintereinander, denn der Linefeed-Defekt zeigte seine volle Gestalt erst in der zweiten Generation

Kurzreferenz: XFA-Save-Fidelity-Checkliste

  • pdfium.v8.dll ab v3.125.2 für XFA-Formulare ausliefern, und ab v3.125.3 für die gewöhnliche pdfium.dll, damit der Final-Write-Fix in beiden steckt
  • LibraryName auf einen vollständigen Pfad zeigen lassen und EnableV8Engine auf True setzen; ein fehlender Pfad schlägt fehl, statt eine andere Kopie zu laden
  • TPdf.XFA und TPdf.XfaRuntimeAvailable nach dem Öffnen des Dokuments bestätigen
  • ClearFormFieldFocus vor SaveAs aufrufen, damit das fokussierte Feld committet wird
  • Das Boolean-Ergebnis von SaveAs nie ignorieren; ein False-Ergebnis lässt die vorherige Datei an ihrem Platz
  • Verifizieren, indem Sie in einem neuen TPdf wieder öffnen und GetXfaDatasets lesen, mit Fallback auf GetXfaFormPackets für Single-Stream-XFA
  • Testen mit leeren Werten, führenden Leerzeichen, Mehrzeilen-Text, & und einem Supplemental-Plane-Zeichen, über zwei Save-Generationen hinweg
  • Mit expliziten Save-Fehlern rechnen bei DTDs, XMLDSig und Kommentaren innerhalb der Live-Packets von Single-Stream-XFA
  • Verliert ein dynamisches Formular Laufzeit-Geometrie beim Wiederöffnen, die Root-Subform auf restoreState="auto" prüfen, bevor man der Bibliothek misstraut

Für die Callback-Struktur, die die XFA-Runtime von einer Host-Anwendung erwartet, siehe FPDF_FORMFILLINFO Version 2 und das XFA-ABI in Delphi. Die V8-Runtime, der Delphi- und C++Builder-Wrapper und das Viewer-Control sind allesamt Teil von PDFium Component for Delphi and C++Builder, das beide Windows-Runtimes für Win32 und Win64 einschließt