Technischer Artikel

Multi-Select-PDF-Felder in FDF-/XFDF-Round-Trips (Delphi)

HotPDF führt Multi-Select-Listenfeld-Werte als Arrays durch FDF und XFDF hin und zurück, indem es den Feldwert von Anfang bis Ende als Array hält. Seit Version 2.755.0 schreiben ExportLoadedFormToFDF, ExportLoadedInterchangeToFDF und ExportLoadedFormToXFDF jede gewählte Option als eigenen FDF-String bzw. eigenes XFDF-<value>-Element, und die passenden Import-Methoden prüfen jeden Wert gegen die Feldoptionen und bauen die /I-Selektionsindizes neu, bevor sie irgendetwas ändern. Nichts wird unterwegs zu einem String verklebt

Der Fehler, den das fixt, lässt sich leicht reproduzieren. Nehmen Sie ein Bestellformular mit einem Multi-Select-Listenfeld der Produktoptionen, lassen Sie einen Nutzer zwei davon wählen, exportieren Sie die Formulardaten für ein Back-Office-System und importieren Sie die bearbeitete Datei zurück in die PDF. Vor dieser Änderung kam die Liste leer oder falsch zurück. Der Grund: Einer der Export-Werte enthielt einen Zeilenumbruch, und der alte Pfad hatte die Selektionen zu einem einzigen zeilengetrennten String platt gedrückt. Mehrere Selektionen aus diesem String zurückzuholen war nie verlässlich, und mit einem Export-Wert, der selbst einen Zeilenumbruch enthält, kann es überhaupt nicht funktionieren

Warum zerstört das Verkleben von Multi-Select-Werten mit Zeilenumbrüchen den Round Trip?

Die Selektionen zu einem String zu verkleben wirft die Grenzen zwischen den Werten weg, und ein Wert kann den Separator enthalten, kein Importer kann den String also korrekt zurücksplitten. ISO 32000-1 §12.7.4.4 erlaubt dem /V-Eintrag eines Choice-Felds entweder einen einzelnen Text-String oder ein Array von Text-Strings, und eine Liste mit dem MultiSelect-Flag (Bit 22 von /Ff) nutzt die Array-Form, sobald mehr als eine Option gewählt ist. Derselbe Abschnitt definiert /I als Array von 0-basierten Optionsindizes in aufsteigender Reihenfolge, das Viewer nutzen, um zwei Optionen auseinanderzuhalten, die zufällig denselben Export-Wert haben. In HotPDF liest der skalare Getter GetFormFieldValue nur die String-Form, ein Array durch ihn degradierte den Export also zu einem leeren String, und der alte XFDF-Import verklebte wiederholte <value>-Elemente mit LF. Stellen Sie sich eine Option vor, exportiert als Deep, Line Feed, Blue: Nach dem Verkleben könnte Deep\nBlue\nRed zwei Selektionen sein oder drei, und die Datei lässt nicht erkennen, welches. Der Fix war, ganz auf einen Skalar mitten im Round Trip zu verzichten

Alter HotPDF-Multi-Select-Round-Trip, bei dem zwei gewählte Listenfeld-Optionen, eine mit eingebettetem Zeilenumbruch, über den skalaren GetFormFieldValue-Pfad zum einzelnen String Deep, Line Feed, Blue, Line Feed, Red platt gedrückt werden, den downstream-Reader als zwei Selektionen oder als drei parsen können
Multi-Select-Werte zu einem String zu verkleben zerstört die Wertgrenzen, und ein Export-Wert, der selbst einen Zeilenumbruch enthält, macht die platt gedrückte Form mehrdeutig

Was enthalten die exportierten FDF- und XFDF-Dateien?

HotPDF schreibt einen Multi-Select-Wert in FDF als typisiertes Array und in XFDF als ein <value>-Element pro Selektion, die Grenzen bleiben also auf der Platte sichtbar. In FDF behält jeder Eintrag die Schreibweise, die er in der Quell-PDF hatte: Hexadezimale Strings gehen als Hex raus, und Literal-Strings werden von einem einzelnen Helfer escaped, der CR und LF in \r und \n verwandelt. In XFDF trägt die Wurzel xml:space="preserve", wie ISO 19444-1 es verlangt, was bedeutet, dass jeglicher Whitespace in einem Text-Element als Daten zählt. HotPDF schreibt daher Start-Tag, escapeden Text und End-Tag jedes <value> in einem Stück, hält die Einrückung außerhalb des Elements und kodiert CR, LF und TAB als Character References, sodass ein XML-Parser mit Line-Ending-Normalisierung die originalen Bytes nicht ändern kann

Export-Formen, die HotPDF seit 2.755.0 für ein Multi-Select-Listenfeld schreibt: FDF trägt ein typisiertes Array pro Feld mit /V [(Deep Zeilenumbruch Blue) (Red)] und einem Hex-Region-Wert, während XFDF ein Value-Element pro Selektion unter xml:space preserve trägt, sodass Whitespace als Daten zählt
Die Grenzen bleiben auf der Platte sichtbar: FDF hält jede Selektion als eigenes Array-Item und XFDF schreibt jede in ein separates Value-Element, kein Importer muss also raten
<!-- FDF: ein typisiertes Array pro Feld -->
<< /T (options) /V [(Deep\nBlue) (Red)] >>
<< /T (region) /V [<45553132>] >>

<!-- XFDF: ein <value> pro Selektion -->
<xfdf xmlns="http://ns.adobe.com/xfdf/" xml:space="preserve">
  <fields>
    <field name="options">
      <value>Deep&#xA;Blue</value>
      <value>Red</value>
    </field>
  </fields>
</xfdf>

Zwei Export-Grenzfälle sollte man kennen, bevor man den Aufruf-Code schreibt. Erstens baut ExportLoadedFormToFDF den kompletten FDF-Body im Speicher auf, bevor es die Zieldatei anlegt (gefixt in 2.755.1), ein nicht exportierbarer Wert, etwa ein Array, das etwas anderes als Strings hält, wirft also, ohne eine bestehende Datei anzustanzen. Zweitens ist eine leere Selektion an einer Liste, die auch einen Export-Wert aus leerem String anbietet, in XFDF mehrdeutig, denn <value/> kann bedeuten, dass nichts gewählt ist, oder dass die leere Option gewählt ist. ExportLoadedFormToXFDF wirft in dem Fall, statt zu raten, und zwar bevor die Zieldatei geöffnet wird. FDF hat diese Mehrdeutigkeit nicht, denn /V [] und /V [()] sind verschieden. Beide FDF-Exporter überspringen zudem Widget-only-Terminals ohne /T-Namen, passend zum XFDF-Exporter, denn kein Importer könnte diese Einträge je zurück auf ein Feld mappen

var
  Pdf: THotPDF;
  Written: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    begin
      // Multi-Select-Listenfelder werden als /V [(...) (...)] geschrieben
      Written := Pdf.ExportLoadedFormToFDF('order-form.fdf');
      try
        Pdf.ExportLoadedFormToXFDF('order-form.xfdf');
      except
        on E: Exception do
          // Leere Selektion plus leere Export-Option: XFDF kann sie
          // nicht unterscheiden, die bestehende .xfdf-Datei bleibt unangetastet
          ShowMessage('XFDF export refused: ' + E.Message);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Wie validiert HotPDF einen Multi-Select-Wert beim Import?

HotPDF akzeptiert ein importiertes Array nur, wenn das Ziel ein Choice-Feld mit gesetztem MultiSelect-Flag ist und jeder Wert im Array einem Export-Wert im /Opt-Array des Felds entspricht. Jeder Options-Slot wird einmalig verwendet, eine Liste mit zwei Optionen, die denselben Export-Wert b haben, akzeptiert also [<62> <62>] als zwei verschiedene Selektionen und weist ein drittes b zurück. Das neu gebaute /I folgt der /Opt-Reihenfolge und nicht der Reihenfolge der ankommenden Werte, denn §12.7.4.4 verlangt aufsteigende Indizes. HotPDF baut das neue /V und /I als abgekoppelte Objekte und weist sie erst zu, nachdem jeder Wert die Validierung passiert hat, ein abgelehnter Wert hinterlässt also nie ein halbes Array oder veraltete Indizes. Die Kopie wird an das Feld geschrieben, das importiert wird, statt in ein geteiltes Ancestor-Array, Hex-Schreibweisen aus FDF bleiben durch den Save hindurch hex, und Felder, deren Berechnungen von der Liste abhängen, werden zur Neuberechnung markiert. Wenn Sie nur einen einzelnen Wert setzen wollen, läuft das Setzen eines Formularfeldwerts in einer geladenen PDF über den skalaren Pfad, der by design keine Mehrfachselektionen behandelt

HotPDF-Importvalidierung für Multi-Select-Werte: Das Ziel muss ein Choice-Feld mit gesetztem MultiSelect in /Ff sein, jeder ankommende Wert muss einem /Opt-Export-Wert entsprechen, jeder Slot einmalig genutzt, /I wird aufsteigend in /Opt-Reihenfolge neu gebaut, und abgekoppelte /V und /I werden erst zugewiesen, nachdem alle Werte bestanden haben
Jeder ankommende Wert wird gegen die Feldoptionen geprüft, bevor irgendetwas geschrieben wird, ein abgelehnter Wert hinterlässt also nie ein halbes Array oder veraltete Selektionsindizes

Manche andere Tools schreiben reine ASCII-Export-Werte als Hex-Strings ohne Byte Order Mark, etwa <416272>, und exportieren dann XFDF, indem sie diese Hex-Ziffern als Text ausschreiben. Ein strenger Literal-Vergleich auf dem Rückweg scheitert, und der Import bricht ab. Version 2.755.1 fügt einen Retry hinzu: Passt ein Wert zu keiner Option, dekodiert HPDFHexSpellingText den Text als Hex-Payload und vergleicht das Ergebnis erneut. Der Retry greift nur bei Input, der sonst geworfen hätte, einen bereits passenden Wert ändert er also nie. Dasselbe Release ließ außerdem den skalaren und den Array-Pfad denselben Unicode-Dekoder nutzen, der PDFDocEncoding, UTF-16 mit beiden Byte Order Marks und UTF-8 versteht. Davor konnte ein logischer Wert auf dem einen Pfad matchen und auf dem anderen scheitern, in Dokumenten, die Kodierungen mischten

Warum kann eine gültige FDF-Datei beim Parsen trotzdem Felder verlieren?

Ein FDF-Scanner, der hexadezimale Strings nicht verfolgt, kann ein Field-Dictionary halbieren, wenn ein Hex-Wert direkt neben dem Dictionary-Terminator endet. In << /T (region) /V <416273>>> schließt das erste > den Hex-String, aber ein naiver Scanner liest es zusammen mit dem nächsten > als Ende des Dictionarys und lässt das Feld still fallen. Der dateiebene FDF-Importer führte bereits Buch darüber, ob er sich in einem Hex-String befand, und in 2.755.1 tun es die Array- und Dictionary-Scanner hinter ImportLoadedInterchangeFromFDF ebenso. Ein zweites Thema betrifft indirekte Referenzen. Eine FDF-Datei ist ein kleines Dokument in PDF-Syntax mit eigener Objektnummerierung (ISO 32000-1 §12.7.7), ein Wert wie /V [11 0 R] verweist also auf Objekt 11 der FDF-Datei, nicht auf Objekt 11 der PDF, die Sie füllen. Der vereinfachte FDF-Parser in HotPDF löst Referenzen innerhalb der Datei nicht auf, er weist so ein Array also zurück, statt zu lesen, was Objekt 11 im Zieldokument zufällig ist

Datei-, Stream- und XFDF-Importe melden Fehler unterschiedlich

Die drei Import-Routen validieren gleich, melden Fehler aber unterschiedlich, und es lohnt sich, bewusst eine zu wählen. ImportLoadedFormFromFDF überspringt jedes Feld, das die Validierung nicht besteht, und gibt die Anzahl der tatsächlich angewandten Felder zurück, eine niedrigere als erwartete Zahl ist also das einzige Zeichen eines Problems. ImportLoadedInterchangeFromFDF und ImportLoadedFormFromXFDF werfen beim ersten abgelehnten Feld. Jedes Feld wird für sich committet, Felder, die vor der Exception verarbeitet wurden, behalten also ihre neuen Werte. Betrachten Sie keines davon als Transaktion über die ganze Exchange-Datei: Brauchen Sie Alles-oder-Nichts, werfen Sie das geladene Dokument weg, wenn eine Exception auftritt, statt es zu speichern

var
  Pdf: THotPDF;
  Source: TMemoryStream;
  Status: AnsiString;
  Info: THPDFFDFInterchangeInfo;
begin
  Pdf := THotPDF.Create(nil);
  Source := TMemoryStream.Create;
  try
    Source.LoadFromFile('order-form-reviewed.fdf');
    if Pdf.LoadFromFile('order-form.pdf', '') > 0 then
    try
      // Nur Felder; ein Wert außerhalb /Opt oder ein Nicht-Multi-Select-Ziel wirft
      if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
    except
      on E: Exception do
        ShowMessage('Import rejected, nothing saved: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

Die XFDF-Callbacks erweitern, ohne bestehende Aufrufer zu brechen

Die Array-Unterstützung in der tieferen XFDF-Unit wohnt in einem separaten Record, THPDFXFDFArrayAccess, und in neuen Overloads von HPDFXFDFExportFields und HPDFXFDFImportFields, nicht in zusätzlichen Feldern am Ende des bestehenden THPDFXFDFAccess-Records. Der Grund ist binäre Kompatibilität. Code, der THPDFXFDFAccess als lokale Variable füllt, setzt oft nur die Slots, die er kennt, und leert den Rest nie, ein neuer Funktionspointer in diesem Record enthielte also Stack-Müll, und die Bibliothek hielte ihn für einen echten Callback. Mit separatem Record behalten alte Aufrufer das alte Layout und die alten Overloads, und diese Overloads reichen intern einen All-Nil-Array-Record durch. Der ursprüngliche skalare Import-Overload verklebt wiederholte Werte weiterhin mit LF, aus Kompatibilität, und nur der Array-bewusste Overload hält sie auseinander. Wenn Sie Ihren eigenen Datenspeicher anbinden, starten Sie von Default(THPDFXFDFArrayAccess). Geben Sie aus GetFormFieldValueArray True für jedes Listenwert-Feld zurück, auch für eines, in dem nichts gewählt ist, und False, um auf den skalaren Callback auszuweichen

uses HPDFXFDF;

// Schlichter Funktionspointer, nicht "of object": Context trägt Ihren eigenen Speicher
function StoreGetSelections(Context: Pointer; FieldIndex: Integer;
  out Values: THPDFXFDFValueArray): Boolean;
begin
  Result := TFormStore(Context).IsListField(FieldIndex);
  if Result then
    Values := TFormStore(Context).Selections(FieldIndex);
end;

procedure ExportStore(Store: TFormStore; out Bytes: TBytes);
var
  Access: THPDFXFDFAccess;
  ArrayAccess: THPDFXFDFArrayAccess;
begin
  Access := MakeStoreAccess(Store);             // Ihre bestehenden skalaren Bindungen
  ArrayAccess := Default(THPDFXFDFArrayAccess); // jeder unbenutzte Slot ist nil
  ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
  HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;

Multi-Select-Austausch funktioniert an Listenfeldern, die bereits existieren und das MultiSelect-Bit in /Ff gesetzt haben. Wie Choice-Felder und ihre Flag-Bits überhaupt angelegt werden, zeigt ListBox und weitere AcroForm-Felder zu einer geladenen PDF hinzufügen. Für Kommentar-Markup, das durch den <annots>-Baum von XFDF läuft, siehe XFDF-Annotation-Import und -Export in HotPDF. Die volle API-Referenz und den Trial-Download finden Sie auf der HotPDF-Delphi-PDF-Component-Seite