Technischer Artikel

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

Ein Listenfeld-Export-Wert aus Deep, einem Line Feed und Blue ist völlig legales PDF, und eine zweite Option namens Red ebenso. Wählt man beide und flattet die Selektion für eine Exchange-Datei in einen LF-getrennten String, empfängt die Gegenseite Deep\nBlue\nRed: zwei Selektionen oder drei, und nichts in den Bytes sagt, welches. Multi-Select-Werte kommen nur dann heil aus FDF oder XFDF zurück, wenn sie die ganze Strecke über Arrays bleiben, von dem Moment, in dem sie die PDF verlassen, bis zu dem, in dem sie zurückgeschrieben werden

Warum kann ein mit Zeilenumbrüchen verklebter String nie einen Multi-Select-Wert tragen?

Ein verklebter String verliert die Grenzen zwischen den Werten, und weil ein Wert den Separator selbst enthalten darf, kann kein Importer diese Grenzen wieder einsetzen. ISO 32000-1 §12.7.4.4 lässt den /V-Eintrag eines Choice-Felds entweder einen einzelnen Text-String oder ein Array von Text-Strings halten; 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, ein Array von 0-basierten Optionsindizes in aufsteigender Reihenfolge, mit dem ein Viewer zwei Optionen auseinanderhält, die denselben Export-Wert haben. Keine der beiden Strukturen passt durch eine String-typisierte API. GetFormFieldValue in HotPDF liefert einen AnsiString und liest die String-Form von /V, jeder darauf gebaute Export kann also ohnehin nur einen Skalar pro Feld schreiben

Die Importseite hat das spiegelbildliche Problem. XFDF schreibt jede Selektion in ein eigenes <value>-Element, und ein Importer, der diese einem skalaren Setter gibt, muss sie erst zusammenkleben, meist mit LF. Bei der Deep/Blue-Option oben ist dieser Kleber vom Datenmaterial nicht zu unterscheiden. HotPDF hält Skalare deshalb ganz aus der Mitte des Round Trips heraus: ExportLoadedFormToFDF, ExportLoadedInterchangeToFDF und ExportLoadedFormToXFDF lesen das Array direkt aus dem Feld, und die passenden Import-Methoden bauen ein neues Array, ohne jemals durch einen verklebten String zu gehen

Naiver 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 die exportierten FDF- und XFDF-Dateien enthalten

Auf der Platte bleiben die Grenzen sichtbar. In FDF wird ein Multi-Select-Feld zu einem typisierten Array, und jedes Item behält die Schreibweise aus der Quell-PDF: Hexadezimale Strings gehen als Hex raus, Literal-Strings als Literale mit CR und LF escaped als \r und \n. In der Datei, die ExportLoadedFormToFDF schreibt, reiten ein Rich-Text-/RV oder ein lokales /Ff ungleich null im selben Field-Dictionary mit. In XFDF deklariert das Wurzelelement xml:space="preserve", was ISO 19444-1 verlangt und was jedes Whitespace-Zeichen innerhalb eines Text-Elements zu Daten macht. Das ist eine Falle für Pretty-Printing. HotPDF schreibt Start-Tag, escapeden Text und End-Tag jedes <value> in eine Zeile, hält die Einrückung außerhalb des Elements und kodiert CR, LF und TAB als Character References, damit ein XML-Parser mit Line-Ending-Normalisierung kein CR-LF-Paar in ein einzelnes LF umschreiben kann

Export-Formen, die HotPDF 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 zählen, wenn man den Aufruf-Code schreibt. ExportLoadedFormToFDF baut den kompletten FDF-Body im Speicher auf und legt die Zieldatei erst danach an, also wirft ein nicht exportierbarer Wert (etwa ein Array, das etwas anderes als Strings hält), während die bisherige .fdf auf der Platte noch unangetastet ist. Der zweite Fall ist eine echte Lücke in XFDF selbst: eine leere Selektion an einer Liste, die auch einen Export-Wert aus leerem String anbietet. <value/> kann "nichts gewählt" oder "die leere Option gewählt" bedeuten, und das Format hat keine dritte Schreibweise. ExportLoadedFormToXFDF wirft in dieser Situation, bevor die Zieldatei geöffnet wird. FDF hat das Problem nicht, denn /V [] und /V [()] sind verschiedene Objekte. Alle drei Exporter überspringen Widget-only-Terminals ohne /T-Namen, denn kein Importer könnte so einen Eintrag 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 wird eine importierte Selektion gegen /Opt geprüft?

HotPDF akzeptiert ein ankommendes Array nur, wenn das Ziel ein Choice-Feld mit gesetztem MultiSelect-Flag ist und jeder Wert einem Export-Wert im /Opt-Array des Felds entspricht (bei einem zweiteiligen Optionspaar dem ersten Element). Jeder Options-Slot wird einmalig verwendet. Eine Liste mit zwei Optionen, die beide b exportieren, akzeptiert also [<62> <62>] als zwei verschiedene Selektionen und weist ein drittes b zurück, weil kein unbenutzter Slot mehr dafür übrig ist. Das neu gebaute /I folgt der /Opt-Reihenfolge, gleichgültig, in welcher Reihenfolge die Werte ankamen, was die Indizes aufsteigend hält, wie §12.7.4.4 es verlangt. Das neue /V und /I werden als abgekoppelte Objekte gebaut und erst zugewiesen, nachdem jeder Wert bestanden hat, ein abgelehnter Wert kann also kein halbes Array oder veraltete Indizes auf dem Feld hinterlassen

Nach der Zuweisung bekommt das Feld einen frischen Appearance-Stream. Diesen Schritt vergisst man in einem handgestrickten Importer leicht, und das Symptom verwirrt: Viewer zeichnen den gespeicherten /AP, nicht /V, die alte Selektion bleibt also auf dem Schirm und im Druck, obwohl sich die Daten geändert haben. Das Listenfeld kommt außerdem in die Changed-Field-Menge, ein parameterloser Aufruf von RecalculateLoadedFormFieldsIncremental führt also genau die Berechnungen neu aus, die es lesen. Hex-Schreibweisen, die aus FDF ankommen, bleiben durch den Save hindurch hex. Wer nur einen einzelnen Skalarwert setzen will, für den ist das Setzen eines Formularfeldwerts in einer geladenen PDF der richtige 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

Ein Eingabemuster verdient Sonderbehandlung. Eine PDF kann einen schlichten ASCII-Export-Wert als Hex-String ohne Byte Order Mark speichern, <416272> für Abr, und ein Exporter, der den PDF-String-Body wortwörtlich kopiert, produziert XFDF, dessen Elementtext die rohen Ziffern 416272 sind statt des dekodierten Texts. XFDF, das einen hex-kodierten Export-Wert als rohe Ziffern trägt, kann nicht per Schreibweise gegen /Opt matchen: Ein strenger Vergleich findet keine Option namens 416272, und der Import schlägt fehl. Bekommt der XFDF-Importer in HotPDF so eine Datei, wiederholt er den nicht gematchten Wert einmal, indem er ihn als Hex-Payload dekodiert. Der Retry ist bewusst eng geschnürt. Er läuft nur für einen Wert, der überhaupt kein exaktes Match gefunden hat; jedes exakte Match wird zuerst reserviert, eine gerettete Schreibweise kann also nie einen Options-Slot nehmen, den ein später, exakt geschriebener Wert braucht; und das gespeicherte /V-Item ist der eigene PDF-String der Option, /V und /I beschreiben also dieselbe Selektion. Das ist die richtige Dosis Nachsicht: Sie zündet nur dort, wo der Import sonst geworfen hätte, und ein bereits gematchter Wert wird nie neu interpretiert. Das Matching selbst läuft durch einen einzigen Unicode-Dekoder, der PDFDocEncoding, UTF-16 mit beiden Byte Order Marks und UTF-8 versteht, derselbe logische Text matcht also dieselbe Option, auch in Dokumenten, die Kodierungen mischen

Zwei FDF-Parse-Fallen: Hex-Terminatoren und fremde Objektnummern

Die erste Falle ist lexikalisch. In << /T (region) /V <416273>>> schließt das erste > den Hex-String und die nächsten beiden schließen das Dictionary. Ein Scanner, der nur nach >>-Paaren sucht, beendet das Dictionary ein Zeichen zu früh und verliert das Feld. HotPDFs dateiebener FDF-Importer führt Buch darüber, ob er sich beim Zerlegen des /Fields-Arrays in einem Hex-String oder Literal befindet, und der Stream-basierte Reader hinter ImportLoadedInterchangeFromFDF tokenisiert die ganze Datei als PDF-Syntax, beide sehen also drei schließende Klammern, wo ein naiver Scan zwei sieht

Die zweite Falle betrifft Referenzen. Eine FDF-Datei ist ein kleines Dokument in PDF-Syntax mit eigener Objektnummerierung (ISO 32000-1 §12.7.7), /V [11 0 R] meint also Objekt 11 der FDF-Datei, niemals Objekt 11 der zu füllenden PDF. Es gegen das Zieldokument aufzulösen würde stillschweigend das Objekt hereinziehen, das dort zufällig diese Nummer trägt. In Delphi-2009- und späteren Builds dekodiert der Interchange-Reader das FDF als kompletten Objektgraphen, folgt der Referenz innerhalb dieses Graphen, validiert die entstehenden Strings wie jedes Inline-Array und wirft, wenn das FDF Objekt 11 nicht enthält. Der dateiebene ImportLoadedFormFromFDF liest jedes Field-Dictionary für sich, ohne die Objekt-Tabelle des FDF; ein Array aus Referenzen fällt durch seine Nur-Strings-Prüfung, und das Feld wird übersprungen

Importroute wählen: überspringen, werfen oder zurückrollen

Alle drei Import-Routen validieren Multi-Select-Werte gleich. Sie unterscheiden darin, was bei einem abgelehnten Wert passiert. ImportLoadedFormFromFDF überspringt jedes Feld, das scheitert, und gibt die Zahl der tatsächlich angewandten Felder zurück, eine Zahl unter der gesendeten ist also das einzige Störsignal. ImportLoadedFormFromXFDF und ImportLoadedInterchangeFromFDF werfen beim ersten abgelehnten Feld, und jeder Aufruf läuft als eine Transaktion auf dem geladenen Dokument: Verlässt die Exception die Methode, wird jedes Feld (und bei der Interchange-Route jede Annotation), das der Aufruf bereits geändert hatte, zurückgerollt. Ein Interchange-Import, der False liefert, wird ebenfalls zurückgerollt, mit Status und Info-Record geleert

Für Daten, die ein Mensch geprüft und zurückgeschickt hat, eine der werfenden Routen nehmen. Ein halb angewandtes Bestellformular ist schlimmer als ein abgelehntes, denn nichts in der gespeicherten PDF zeigt, welche Listenfelder aus der geprüften Datei stammen und welche ihre alten Werte behielten. Das Skip-and-Count-Verhalten von ImportLoadedFormFromFDF passt zu einer anderen Aufgabe: Best-Effort-Vorbelegung aus einem FDF, das vielleicht für eine leicht andere Revision des Formulars erzeugt wurde

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 mehrere Werte, die an ein
      // Single-Select-Listenfeld gehen, werfen und rollen den Import zurück
      if Pdf.ImportLoadedInterchangeFromFDF(Source, True, False, Status, Info) then
      begin
        // Nur die Berechnungen neu ausführen, die die importierten Felder lesen
        Pdf.RecalculateLoadedFormFieldsIncremental;
        Pdf.SaveLoadedDocument('order-form-filled.pdf');
      end;
    except
      on E: Exception do
        ShowMessage('Import rejected, document unchanged: ' + E.Message);
    end;
  finally
    Source.Free;
    Pdf.Free;
  end;
end;

Den eigenen Datenspeicher an die XFDF-Array-Callbacks binden

Die Unit HPDFXFDF, die zwischen XFDF und jedem per Callbacks beschreibbaren Feldspeicher konvertiert, trägt die Array-Unterstützung in einem separaten Record, THPDFXFDFArrayAccess, und in zusätzlichen Overloads von HPDFXFDFExportFields und HPDFXFDFImportFields. Neue Slots an den bestehenden THPDFXFDFAccess-Record anzuhängen wäre kürzer und falsch gewesen. Aufrufer deklarieren diesen Record oft als lokale Variable und füllen nur die Slots, die sie kennen, ein neuer Funktionspointer am Ende hielte also Stack-Müll, den die Bibliothek für einen Callback hielte. Mit dem separaten Record behält bestehender Code sein Layout und seine Overloads, und diese Overloads reichen intern einen All-Nil-Array-Record durch

Die beiden Import-Overloads verhalten sich bei wiederholten <value>-Elementen unterschiedlich. Der ursprüngliche skalare Overload verklebt sie weiterhin mit LF und ruft SetFormFieldValue, bestehende Aufrufer hängen an dieser Konvention. Der Array-bewusste Overload verklebt nie: Er gibt die Liste an SetFormFieldValueArray weiter, fällt nur für einen einzelnen Wert, den Ihr Callback ablehnt, auf den skalaren Setter zurück, und wirft, wenn Ihr Callback eine Liste mit zwei oder mehr ablehnt. Beim Export von Default(THPDFXFDFArrayAccess) starten, damit die Slots, die Sie nicht zuweisen (einschließlich des optionalen SetFormFieldRV), nil bleiben. Aus GetFormFieldValueArray True für jedes Listenwert-Feld zurückgeben, auch für eines, in dem nichts gewählt ist, und False, um das Feld dem skalaren Callback zu überlassen. Ein leeres Array geht als einzelnes leeres <value/> raus, ein Speicher, der eine Leerer-String-Option anbietet, sollte diesen Fall also im Callback ablehnen, genauso wie ExportLoadedFormToXFDF es tut

uses HPDFXFDF;

// Schlichter Funktionspointer, kein "of object": Context trägt Ihren eigenen Speicher.
// TFormStore und seine Methoden sind Ihr Code, kein Bestandteil von HotPDF
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 ungenutzte Slot bleibt nil
  ArrayAccess.GetFormFieldValueArray := StoreGetSelections;
  HPDFXFDFExportFields(Access, ArrayAccess, Bytes);
end;

Alles oben setzt Listenfelder voraus, die bereits existieren und das MultiSelect-Bit in /Ff bereits gesetzt haben. Choice-Felder anzulegen und ihre Flag-Bits zu setzen, ist in ListBox- und weiteren AcroForm-Feldern zu einer geladenen PDF hinzufügen beschrieben, und Kommentar-Markup, das durch den <annots>-Teil von XFDF reist, in XFDF-Annotation-Import und -Export in HotPDF. Die Methodenreferenz für jeden hier gezeigten Aufruf, dazu ein Testbuild, um sie gegen die eigenen Formulare laufen zu lassen, findet sich auf der HotPDF Delphi PDF-Komponente-Seite