Technischer Artikel

Statische XFA-Formulare zu AcroForm in Delphi umwandeln

PDFium Component wandelt statische XFA-Formulare in gewöhnliche AcroForm-Felder aus Delphi über FlattenPdfXfaToAcroForm in der Unit FPdfDocumentXfaFlatten um. Die Funktion liest die XFA-Template- und Datasets-Pakete, mappt jedes absolut positionierte Feld auf ein AcroForm-Terminalfeld mit eigenem Appearance-Stream und hängt das Ergebnis als inkrementelle Revision an, sodass das konvertierte PDF sich als normales Formular in Viewern öffnet, die nie XFA unterstützt haben

Die Motivation ist meist ein Support-Ticket statt eines Roadmap-Punkts. Ein Regierungs- oder Versicherungsformular wurde in einem Designer erzeugt, der es als XFA speicherte, es funktioniert in einem Desktop-Reader, und überall sonst öffnet es sich als leere Seite mit dem Hinweis, das Dokument benötige einen neueren Viewer. XFA wurde in PDF 2.0 (ISO 32000-2) als veraltet markiert, Browser und mobile Viewer haben es nie implementiert, und PDFium selbst rendert XFA nur mit dem V8-aktivierten Build. Statische XFA-Formulare — jene, deren Layout zur Entwurfszeit feststeht — tragen genug Geometrie im Template, um als AcroForm-Felder neu gebaut zu werden, und genau diese Scheibe behandelt dieser Konverter

Was produziert FlattenPdfXfaToAcroForm tatsächlich?

FlattenPdfXfaToAcroForm produziert eine Byte-Level-AcroForm-Revision, angehängt an eine unveränderte Kopie der Quelle, nie ein Umschreiben der Originaldatei. PDFium legt keine öffentliche API zum Erzeugen von Formularfeldern offen, also geht die Komponente für diesen Job überhaupt nicht durch PDFium: Sie liest die aktive Revision mit ihrem eigenen Objekt-Reader, baut Feld-Dictionarys, Widget-Annotationen, Appearance-XObjects und eine /Helv-Font-Ressource als rohe PDF-Objekte und serialisiert sie durch ein sparsames inkrementelles Update im Sinn von ISO 32000-1 §7.5.6

Das neue /AcroForm-Dictionary trägt /Fields, /DA und /DR und keinen /XFA-Eintrag. Behält die Quelle ihr AcroForm als indirektes Objekt, wird dieses Objekt an Ort und Stelle umgeschrieben; ein Inline-Dictionary wird im Katalog ersetzt. So oder so zeigt die aktive Revision nicht mehr auf die XFA-Pakete, also öffnet ein Viewer, der beide Modelle versteht, die AcroForm-Felder, statt zu versuchen, das XFA-Formular zu setzen. Die ursprünglichen XFA-Bytes stecken weiterhin in der früheren Revision — das ist es, was ein inkrementelles Update bedeutet — aber nichts in der aktuellen Revision referenziert sie

Pipeline der Statisch-XFA-zu-AcroForm-Konvertierung in PDFium Component für Delphi: FlattenPdfXfaToAcroForm liest die Template- und Datasets-Pakete, mappt absolut positionierte Felder auf Feld-Dictionarys und Widgets und hängt das AcroForm als sparsame inkrementelle Revision ohne /XFA-Eintrag an
FlattenPdfXfaToAcroForm berührt die Originalbytes nie: Die konvertierten Felder, Widgets und Appearance-Streams werden als Revision angehängt, deren /AcroForm schlicht aufhört, auf die XFA-Pakete zu zeigen
uses
  System.SysUtils, System.Classes, System.IOUtils, FPdfDocumentXfaFlatten;

procedure ConvertXfaForm(const SourceFile, TargetFile: string);
var
  Source: TFileStream;
  Options: TPdfXfaFlattenOptions;
  OutputPdf: TBytes;
  Report: TPdfXfaFlattenReport;
  Fields: TPdfXfaFlattenFields;
begin
  Options := TPdfXfaFlattenOptions.Default;
  Options.MaxFields := 1024;   // Default ist 512, der erlaubte Bereich ist 1..4096

  Source := TFileStream.Create(SourceFile, fmOpenRead or fmShareDenyWrite);
  try
    if not FlattenPdfXfaToAcroForm(Source, Options, OutputPdf, Report, Fields) then
      raise Exception.Create('XFA conversion failed: ' + Report.ErrorText);
  finally
    Source.Free;
  end;

  TFile.WriteAllBytes(TargetFile, OutputPdf);
  Writeln(Format('%d template fields, %d bound to datasets, %d written on %d pages',
    [Report.TemplateFields, Report.DatasetBound, Report.FieldsWritten, Report.PagesWritten]));
end;

Die Funktion gibt False zurück und füllt Report.ErrorText für jede abgelehnte oder fehlerhafte Eingabe, und sie stellt die Quellstream-Position beim Verlassen wieder her. Abbruch ist der eine Ausnahmepfad, der sich fortpflanzt: Feuert Options.CancellationToken, wird EPdfOperationCancelled erneut geworfen, nachdem der Report gefüllt ist. TPdfXfaFlattenReport trägt zudem SourceSha256 und OutputSha256, praktisch, wenn ein Batch-Job beweisen muss, welche Eingabe welche konvertierte Datei produziert hat. TPdfXfaFlattenOptions.Default setzt ein Paketbudget von 8 MiB, 512 Felder und eine Ausgabeobergrenze von 512 MiB; die Quelle darf nicht verschlüsselt sein

Die Template- und Datasets-Pakete lesen

PDFium Component akzeptiert beide Layouts, die ISO 32000-1 §12.7.8 für den /XFA-Eintrag erlaubt: die Array-Form, die Paketnamen und Stream-Referenzen abwechselt, und die Single-Stream-XDP-Form, in der ein Stream das ganze <xdp:xdp>-Dokument hält. Paket-Streams dürfen unkomprimiert oder /FlateDecode ohne Predictor sein, und die Dekompression ist durch MaxPacketBytes begrenzt

Der Single-Stream-Fall ist der, an dem naive XML-Behandlung scheitert. Designer schreiben üblicherweise <xfa:datasets> mit Namespace-Präfix, während das Template unpräfixiert sitzt, also findet das Abschneiden nach literalem Tag-Namen ein Paket und verpasst das andere. Der Konverter gleicht Element- und Attributnamen über den lokalen Namen ab, nach einer separaten Prüfung, dass sich öffnende und schließende Tags über ihren vollständigen präfixierten Namen paaren. Wollen Sie nur die Pakete ansehen statt sie zu konvertieren, behandelt der Leitfaden zum Erkennen von XFA-Formularen und Extrahieren ihrer Pakete mit PDFium VCL diesen Nur-Lese-Pfad

Feldwerte kommen von zwei Stellen. Ein <value>-Element im Template liefert den Default, und das Datasets-Paket liefert die ausgefüllten Daten, gebunden über den Feldnamen. Feldnamen wiederholen sich in echten Formularen, also vergleicht der Binder die Kette der benannten <subform>-Vorfahren mit dem Vorfahrenpfad in den Daten und nimmt das längste nicht kollidierende Match. Teilen zwei Blattfelder einen Namen unter verschiedenen Subforms, wird das AcroForm-/T zu SubformPath.Name, damit die beiden Felder nach der Konvertierung unterschieden bleiben

Warum braucht choiceList ein explizites Combo-Flag?

XFA und PDF haben entgegengesetzte Defaults für Auswahlfelder, also macht ein Konverter, der keine Flags kopiert, aus jeder Dropdown-Liste ein Listenfeld. Eine XFA-<choiceList> mit dem Default open="userControl" oder mit open="onEntry" ist eine Dropdown-Liste; ein PDF-/FT /Ch-Feld ohne Flags ist ein scrollbares Listenfeld, und nur das Combo-Flag (Bitposition 18 in ISO 32000-1 Tabelle 230, Wert 131072, dasselbe Bit, das PDFium FPDF_FORMFLAG_CHOICE_COMBO nennt) macht daraus ein Kombinationsfeld. Der Konverter setzt das Combo-Flag daher für die Dropdown-Fälle und lässt die Flags nur leer, wenn das Template open="always" oder open="multiSelect" sagt, was die Liste dauerhaft offen hält. Builds vor 3.181.1 vertauschten die Combo- und Pushbutton-Werte und suchten nach einem nicht standardmäßigen open="on" — mit ihnen geflattete Formulare sind also einen erneuten Lauf wert

Flag-Entscheidung für Auswahlfelder bei der Statisch-XFA-zu-AcroForm-Konvertierung in PDFium Component: ein open-Attribut von userControl oder onEntry setzt das Combo-Flag, Bit 18, Wert 131072, und erzeugt eine Dropdown-Kombobox, während open always oder multiSelect die Flags für ein dauerhaft offenes Listenfeld leer lässt
Ohne das Combo-Flag flachtet eine XFA-Dropdown-Liste zu einem scrollbaren Listenfeld ab, also liest der Konverter das open-Attribut und setzt Bit 18 nur für die Dropdown-Fälle

Der Rest des UI-Mappings folgt den Field-Flag-Tabellen in ISO 32000-1 §12.7.4:

  • textEdit, numericEdit und dateTimeEdit werden /FT /Tx; ein mehrzeiliges Textfeld bekommt das Multiline-Flag (/Ff 4096, Tabelle 228) und einen zeilengetrennten Appearance-Stream
  • passwordEdit wird /FT /Tx mit dem Password-Flag (/Ff 8192) und einer Darstellung, die ein Sternchen pro Unicode-Skalar zeichnet, nicht pro UTF-16-Code-Einheit
  • checkButton wird eine /FT /Btn-Checkbox, deren Wert /Yes ist, wenn die gebundenen Daten 1 sind, und /Off sonst
  • <exclGroup> wird ein /FT /Btn /Ff 32768-Radio-Parent (Tabelle 226) mit /V, /DV, /Opt und /Kids, plus ein Child-Widget pro Member mit eigenem /AS und zweistufigem /AP
  • imageEdit wird ein Pushbutton, dessen Darstellung das Bild malt; ein Baseline-JPEG wird unverändert unter /DCTDecode eingebettet, während PNG dekodiert und als /FlateDecode-Pixel neu kodiert wird, mit einem zusätzlichen /SMask, wenn das PNG Alpha- oder tRNS-Transparenz trägt

Werte, die kein schlicht druckbares ASCII sind, eingeschlossen Zeilenumbrüche, werden als <FEFF...>-UTF-16BE-Strings geschrieben, sodass ein japanischer Name oder eine zweizeilige Adresse die Reise übersteht. Die Fields-Ausgabe spiegelt, was geschrieben wurde, was sie zu einem bequemen Audit-Trail macht

procedure DumpConvertedFields(const Fields: TPdfXfaFlattenFields);
const
  KindNames: array[TPdfXfaFlattenFieldKind] of string = (
    'text', 'check', 'choice', 'image', 'numeric', 'dateTime', 'password', 'radio');
var
  I: Integer;
  F: TPdfXfaFlattenField;
begin
  for I := 0 to High(Fields) do
  begin
    F := Fields[I];
    Writeln(Format('%s.%s [%s] page %d at %.1f,%.1f size %.1f x %.1f',
      [F.SubformPath, F.Name, KindNames[F.Kind], F.PageIndex + 1, F.X, F.Y, F.W, F.H]));
    case F.Kind of
      fxfkChoice:
        Writeln(Format('  %d items, list box: %s', [Length(F.Choices), BoolToStr(F.ListBox, True)]));
      fxfkRadio:
        Writeln(Format('  %d radio widgets, value "%s"', [Length(F.RadioWidgets), F.Value]));
      fxfkImage:
        Writeln(Format('  %d image bytes', [Length(F.ImageData)]));
    else
      Writeln('  value "' + F.Value + '"');
    end;
  end;
end;

Geometrie, Maßeinheiten und Seitenrouting

XFA-Geometrie wird in den PDF-User-Space konvertiert, indem jedes Feld an der MediaBox-Höhe seiner eigenen Seite gespiegelt wird, denn XFA misst von der Seitenoberkante nach unten und PDF von der Unterkante nach oben. Offsets aus verschachtelten <subform>-Elementen akkumulieren sich auf dem Weg nach unten durch das Template, und jedes Feld muss mit positiver Breite und Höhe enden, sonst wird die Konvertierung abgewiesen

XFA-zu-PDF-Geometriekonvertierung in PDFium Component für Delphi: ein von der Oberkante nach unten gemessenes Feldrechteck im XFA-Template wird an der MediaBox-Höhe seiner eigenen Seite in den PDF-User-Space gespiegelt, in dem die y-Koordinate von unten nach oben wächst
Das nach unten messende XFA-Template und der nach oben messende PDF-User-Space treffen sich in einer einzigen Subtraktion gegen die MediaBox-Höhe, die jedes konvertierte Widget an seine entworfene Position setzt

Messwerte akzeptieren die Einheiten, die echte Designer emittieren: nackte Zahlen und pt, dazu in (72 Punkte), mm (72 / 25.4), cm (72 / 2.54), pc (12), px (0.75) und mp (0.001, Millipunkte). Das Parsen ist locale-unabhängig, eine deutsche Windows-Installation macht also nicht aus 8.5in Unsinn. Ein unbekannter Einheiten-Suffix wird abgewiesen, statt als Punkte gelesen zu werden, was das Feld still verfehlen würde

Das Seitenrouting folgt dem <pageSet>: Geschwisterliche <pageArea>-Elemente werden ab eins in Deklarationsreihenfolge nummeriert und beanspruchen die unter ihnen verschachtelten Felder, während Felder ohne <pageArea>-Vorfahren auf Seite eins bleiben. Jede Seite bekommt ihr eigenes /Annots-Update. Ein Feld, das hinter die letzte Seite des PDFs geroutet wird, ist ein Fehler statt eines Widgets, das auf Seite eins fallen gelassen wird

Welche XFA-Formulare weist der Konverter ab?

Der Konverter weist alles ab, dessen Layout sich ohne Ausführen der XFA-Layout-Engine nicht kennen lässt. Das heißt: ein layout-Attribut außer position an einem <subform> oder <exclGroup> (flussige tb, lr-tb und Verwandte, woraus dynamische Formulare gebaut werden), jedes <script>-Element und jede UI-Art außer den sieben oben aufgeführten. Dynamische Formulare, die zur Laufzeit Seiten nachlegen, gehören in eine andere Werkzeugkette; der Artikel zu dynamischer XFA-Repaginierung und Seitenzahlen in PDFium Component zeigt, was dieses Live-Layout bedeutet

Die Ablehnung ist absichtlich. Eine flussige Subform hat keine festen Koordinaten, jede "Konvertierung" müsste also Positionen erfinden, und ein skriptgesteuertes Formular berechnet oft Summen oder schaltet Sichtbarkeiten um, die ein statisches AcroForm nicht reproduzieren kann. Ein PDF zu produzieren, das vollständig aussieht, aber still eine berechnete Summe verloren hat, ist schlimmer als ein klarer Fehler in Report.ErrorText. Die XFA-3.3-Spezifikation definiert weit mehr als diese Teilmenge, und der Konverter gibt nicht vor, anders zu sein

Fallstricke beim Schreiben und Verifizieren der AcroForm-Revision

Zwei Probleme zeigten sich beim Bau des Writers, und beide treffen jeden, der inkrementelle Updates von Hand produziert. Das erste: Der Katalog muss stets Teil des Update-Sets sein, selbst wenn seine Bytes sich nicht ändern, weil das AcroForm ein woanders umgeschriebenes indirektes Objekt ist. Der Sparse-Update-Writer leitet den Trailer-/Root aus den Objekten ab, die er trägt, und ein Update ohne Root-Objekt produziert eine Revision, an der sich Viewer nicht verankern können

Das zweite betrifft die Verifikation. Das Laden der konvertierten Datei durch den PDFium-Wrapper stößt die Formular-Umgebungsinitialisierung an, sobald das Dokument ein /AcroForm hat, und das Host-pdfium.dll im Testsetup mangelte der FORM_GetTextDirection-Export, also scheiterte die Initialisierung, bevor ein einziges Feld geprüft werden konnte. Rücklese-Prüfungen verwenden daher den Komponenten-Objekt-Reader, der die aktive Revision parst, ohne die Formularschicht anzufassen

uses
  System.SysUtils, System.Classes, FPdfCompress;

function HasActiveAcroForm(const Pdf: TBytes): Boolean;
var
  Stream: TBytesStream;
  Objects: TPdfActiveObjects;
  Revision: TPdfSparseRevisionInfo;
  ErrorText: string;
begin
  Stream := TBytesStream.Create(Pdf);
  try
    if not ReadPdfActiveObjects(Stream, TPdfActiveObjectReadOptions.Default,
      Objects, Revision, ErrorText) then
      raise Exception.Create(ErrorText);
    // der aktive Katalog muss auf ein AcroForm verweisen; ein vom Konverter
    // geschriebenes Inline-AcroForm trägt keinen /XFA-Eintrag
    Result := Pos('/AcroForm', string(Revision.CatalogDictionary)) > 0;
  finally
    Stream.Free;
  end;
end;

Appearance-Streams sind bewusst schlicht: Helvetica bei 10 Punkten für Text- und Auswahloptionen, ein gestricheltes Quadrat für Checkboxen, ein gefülltes inneres Quadrat für den gewählten Radio-Zustand. Sie machen das konvertierte Formular überall lesbar, aber sie sind keine Pixelkopie des ursprünglichen XFA-Renderings, also werden im Template definierte Schriften, Farben und Rahmen nicht übernommen. Muss ein Formular identisch zur Designer-Ausgabe aussehen, rendern Sie es einmal mit dem XFA-Build und vergleichen, bevor Sie eine Produktions-Pipeline umstellen

FAQ

Braucht FlattenPdfXfaToAcroForm den V8- oder XFA-Build von PDFium?

Nein. FlattenPdfXfaToAcroForm parst die Pakete und schreibt die Revision mit PDFium-Component-Code, es funktioniert also mit der Standard-pdfium.dll und führt keinerlei XFA-Skript aus

Kann der Konverter verschlüsselte XFA-Dokumente behandeln?

Nicht direkt. Eine verschlüsselte Quelle wird mit einem Fehler abgewiesen, entschlüsseln Sie das Dokument also zuerst und konvertieren Sie die entschlüsselte Kopie

Bleiben die konvertierten Felder editierbar?

Ja. Die Ausgabe enthält echte AcroForm-Text-, Checkbox-, Radio-, Auswahl- und Pushbutton-Felder mit Werten und Appearance-Streams, Nutzer können das Formular also in jedem AcroForm-fähigen Viewer weiter ausfüllen

Die Konvertierung statischer XFA-Formulare, die XFA-Paketextraktion und die V8-aktivierte XFA-Laufzeit sind alle Teil des PDFium Component for Delphi and C++Builder, das PDFium für Delphi-, C++Builder- und Lazarus-Projekte umhüllt