Technischer Artikel

PDF/A-3-Anhängedateien und AFRelationship in Delphi

Um aus Delphi eine Quelldatei an ein PDF/A-3-Dokument anzuhängen, schreibt PDFium Component eine PDF-2.0-Associated-File-Kette: einen Embedded-File-Stream mit MIME-/Subtype, eine Dateispezifikation mit /AFRelationship und ein /AF-Array, das am Katalog oder an einer Seite hängt. InjectAssociateFiles und TPdf.SaveAsWithAssociateFiles bauen diese Kette in einem einzigen inkrementellen Update, und seit v3.121.2 wird der MIME-Typ als einzelner, korrekt escapeter PDF-Name serialisiert. Der Rest dieses Artikels behandelt, was ein Validator prüft, den Ein-Zeichen-Bug, der text/plain zerbrach, und die Stellen, an denen ältere Releases stillschweigend etwas anderes taten, als Sie verlangt hatten

Was braucht eine PDF/A-3-Anhängedatei wirklich?

Eine PDF/A-3-Anhängedatei besteht die Validierung nur, wenn drei Objekte sich gegenseitig bestätigen: Der Embedded-File-Stream deklariert /Type /EmbeddedFile plus einen MIME-/Subtype, das File-Specification-Dictionary (ISO 32000-2 §7.11.3) trägt /F, /UF, /EF und /AFRelationship, und etwas im Dokument referenziert diese Dateispezifikation über ein /AF-Array (ISO 32000-2 §14.13). Pures Einbetten über den /Names /EmbeddedFiles-Baum, wie es TPdf.CreateAttachment tut, setzt die Assoziationsfelder überhaupt nicht. Das eigene PDF/A-3b-Validierungs-Fixture der PDFium Component macht die Abhängigkeit konkret: Nur den /AFRelationship-Schlüssel umbenennen, und die Datei fällt durch genau eine Regel in Clause 6.8 von ISO 19005-3; nur den MIME-/Subtype weglassen, und eine andere 6.8-Regel fällt; dieselbe Anhängung in einen PDF/A-1b-Kandidaten stecken, und sie wird rundheraus abgelehnt, denn PDF/A-1 verbietet Embedded Files, egal wie ordentlich die Metadaten sind

Die Drei-Objekt-Kette einer PDF/A-3-Anhängedatei in PDFium Component: ein EmbeddedFile-Stream mit MIME-Subtype wie application xml, eine Dateispezifikation mit F, UF, EF und AFRelationship auf Data, und ein AF-Array dafür vom Katalog oder einer Seite – die drei Objekte, die ein Validator prüft, bevor Clause 6.8 von ISO 19005-3 besteht
Stream, Dateispezifikation und AF-Array müssen übereinstimmen; das pure Name-Tree-Embedding von TPdf.CreateAttachment setzt keines der Assoziationsfelder und wird es nie

Der Relationship-Wert ist der Teil, bei dem Leute gern raten. TPdfAFRelationship in FPdfAssocFiles mappt ein Enum-Mitglied auf jeden Namens-Token, die der Injector emittieren kann, und nur die ersten fünf gehören zur Teilmenge, die ISO 19005-3 anerkennt:

  • afSource → /Source: das Original, aus dem das PDF erzeugt wurde, etwa eine Textverarbeitungsdatei oder ein Spreadsheet
  • afData → /Data: maschinenlesbare Daten, aus denen der sichtbare Inhalt abgeleitet wurde oder die er repräsentiert
  • afAlternative → /Alternative, afSupplement → /Supplement, afUnspecified → /Unspecified
  • afEncryptedPayload, afFormData, afTemplate: PDF-2.0-Ergänzungen, die außerhalb der PDF/A-3-Teilmenge liegen, also aus Archivausgaben heraushalten

Warum brach /Subtype /text/plain die Validierung?

Der MIME-Bug war ein Tokenisierungsfehler, keine Compliance-Lücke: Vor v3.121.2 konkatenierte der Injector den String des Aufrufers direkt hinter einen Slash, woraus /Subtype /text/plain wurde. In der PDF-Syntax startet der zweite Slash ein neues Name-Objekt (ISO 32000-1 §7.3.5), also hielt das Stream-Dictionary plötzlich den Schlüssel /Subtype, den Namen /text und einen herrenlosen Extra-Namen /plain, der die Key-Value-Paare aus dem Gleichgewicht brachte. Ein unabhängiger PDF/A-Validator wies die Datei ab, während er das EmbeddedFile-Dictionary parste, bevor er überhaupt zu einer PDF/A-Regel kam – deshalb sah der Fehler nach Dateikorruption aus und nicht nach einer fehlenden Attachment-Eigenschaft

Der Fix schickt den MIME-Wert durch EscapePdfName, das /text#2Fplain emittiert: ein Name, dessen dekodierter Wert text/plain ist. Das Escaping ist bewusst breiter als der Slash. Jedes Byte von 32 abwärts (Leerzeichen, Tab, CR, LF), jedes Byte ab 127 aufwärts, die Delimiter ()<>[]{}/% und das #-Escape-Zeichen selbst werden zu #XX. Nur den Slash zu escapen hätte ein anderes Loch offengelassen: Ein MIME-String mit >> oder Whitespace könnte das Dictionary vorzeitig schließen oder Extra-Keys einschleusen, also füttert der Regressionstest einen feindseligen Wert mit jedem Delimiter plus Tab, LF und CR und prüft die exakt kodierte Ausgabe

Warum der MIME-Subtype text slash plain das PDF/A-3-Parsing in PDFium Component zerbrach: Die Konkatenation des Werts hinter einem Slash erzeugte zwei Name-Objekte, /text als Wert plus ein herrenloses /plain, das das EmbeddedFile-Dictionary aus dem Gleichgewicht brachte, und der v3.121.2-Fix schickt den Wert durch EscapePdfName, sodass /text#2Fplain ein Name ist, der zu text/plain dekodiert
Der Fehler sah nach Dateikorruption aus, weil er am Parser passierte, vor jeder PDF/A-Regel; der escapte Name hält die Paare im Gleichgewicht und den Validator am Lesen
// Was der Injector für MIMEType = 'text/plain' schreibt
//   vor v3.121.2:  /Type /EmbeddedFile /Subtype /text/plain     (zwei Namen)
//   v3.121.2:      /Type /EmbeddedFile /Subtype /text#2Fplain   (ein Name)
//
// Aufrufer übergeben stets den gewöhnlichen MIME-Wert. Ihn selbst vorab
// zu escapen kodiert das '#' doppelt, aus 'text#2Fplain' wird 'text#232Fplain'
Options.Files[0].MIMEType := 'text/plain';

Eine PDF/A-3-Datei mit InjectAssociateFiles bauen

Erzeugen Sie für PDF/A-3-Ausgaben das konforme Basisdokument mit TPdf.SaveAsPdfAToStream und rufen Sie dann InjectAssociateFiles auf diesem Stream auf; genau diese Zwei-Schritte-Pipeline läuft im Validierungs-Fixture, bevor es PDF/A-3b besteht. TPdf.SaveAsWithAssociateFiles ist der Komfort-Wrapper, aber er speichert über den gewöhnlichen SaveAs-Pfad mit saRemoveSecurity statt über den PDF/A-Writer, also fügt er weder die XMP-Identifikation noch den Output Intent hinzu, die PDF/A verlangt. Beachten Sie, dass die Record-Typen in FPdfAssocFiles und FPdfPdfa wohnen, also gehören beide Units in Ihre uses-Klausel. Seit v3.121.3 müssen FileName und Description nicht mehr pures ASCII sein: /UF und /Desc werden als PDF-Text-Strings geschrieben, druckbares ASCII wörtlich und alles andere als UTF-16BE mit Byte-Order-Mark, während der Legacy-/F-Name stets portables druckbares ASCII ist, bei dem jedes andere Zeichen durch _ ersetzt wird – Reader, die /F mit ihrer eigenen Codepage dekodieren, zeigen also einen Unterstrich statt Mojibake. Frühere Builds wandelten alle drei über die System-ANSI-Codepage auf Delphi um oder schrieben rohe UTF-8-Bytes auf Free Pascal; halten Sie Namen also nur dann ASCII, wenn ältere Builds dieselbe Ausgabe produzieren müssen

uses
  System.SysUtils, System.Classes, System.IOUtils,
  PDFium, FPdfPdfa, FPdfAssocFiles;

procedure SaveWithSourceData(Pdf: TPdf; const XmlPath, OutPath: string);
var
  PdfAOptions: TPdfASaveOptions;
  Options: TAssocFilesOptions;
  Base: TMemoryStream;
  Output: TFileStream;
begin
  PdfAOptions := TPdfASaveOptions.Default;
  PdfAOptions.Conformance := pac3b;

  Options := TAssocFilesOptions.Default;      // TargetPage = 0: /AF auf Katalog-Ebene
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'invoice-data.xml';
  Options.Files[0].Description := 'Structured invoice data';
  Options.Files[0].Content := TFile.ReadAllBytes(XmlPath);
  Options.Files[0].Relationship := afData;
  Options.Files[0].MIMEType := 'application/xml';  // wird als /application#2Fxml geschrieben

  Base := TMemoryStream.Create;
  try
    if not Pdf.SaveAsPdfAToStream(Base, PdfAOptions) then
      raise Exception.Create('PDF/A-3 base save failed');
    Output := TFileStream.Create(OutPath, fmCreate);
    try
      InjectAssociateFiles(Base, Output, Options);  // spult Base zurück; wirft EPdfAssocFilesError bei Fehlschlag
    finally
      Output.Free;
    end;
  finally
    Base.Free;
  end;
end;

Katalog oder Seite: Wo landet das /AF-Array?

TAssocFilesOptions.TargetPage entscheidet über den Besitzer des /AF-Arrays: 0 hängt es an den Katalog als Dokument-Level-Assoziation, und 1..N an das jeweilige Seiten-Dictionary, 1-basiert. Der Injector hängt alles als ein einziges inkrementelles Update in festem Layout an (zuerst die Embedded-Streams, dann die Dateispezifikationen, dann das /AF-Array, dann ein neu geschriebenes Katalog- oder Seiten-Objekt), also behalten existierende Objekte ihre Offsets, und nichts wird neu komprimiert. Ein früherer /AF-Eintrag am Ziel-Dictionary wird ersetzt, nicht verschmolzen – das macht ein wiederholtes Speichern idempotent, bedeutet aber auch, dass ein zweiter Aufruf mit anderer Dateiliste gewinnt. Zwei Verhalten verdienten früher eine Absicherung im eigenen Code, und beide haben sich geändert. Vor v3.122.0 scheiterte ein TargetPage außerhalb des Bereichs nicht; es fiel auf den Katalog zurück, also machte ein Tippfehler aus einer Seiten-Level-Assoziation eine Dokument-Level-Assoziation, ganz ohne Signal. Seit v3.122.0 werfen SaveAsWithAssociateFiles und SaveAsWithAssociateFilesToStream ein EPdfError, wenn TargetPage außerhalb 0..PageCount liegt, und InjectAssociateFiles wirft den neuen EPdfAssocFilesError für ein negatives TargetPage oder eines, das keine existierende Seite benennt, und lässt den Ziel-Stream unverändert. Vor v3.121.4 suchte der Seiten-Lookup die gespeicherten Bytes nach /Type /Page-Dictionaries in Dateireihenfolge ab, was die Datei an eine andere Seite hängen konnte, sobald Seiten-Objekte in einer anderen Reihenfolge gespeichert waren als angezeigt, etwa nach dem Umordnen oder Einfügen von Seiten; seit v3.121.4 benennt TargetPage die Seite an dieser Position in der Dokument-Seitenreihenfolge

Wo das AF-Array in PDFium Component landet: TargetPage null hängt es an den Katalog, Seiten 1 bis N an das Seiten-Dictionary, und ein Wert außerhalb des Bereichs, der vor v3.122.0 still zum Katalog zurückfiel, wirft jetzt eine Exception, während der Injector alles als ein inkrementelles Update in festem Layout anhängt, das existierende Offsets hält und jeden früheren AF-Eintrag ersetzt
Vor v3.122.0 wurde ein TargetPage außerhalb des Bereichs still zu einer Dokument-Level-Assoziation; aktuelle Releases werfen stattdessen, und ein zweiter Aufruf mit anderer Dateiliste gewinnt weiterhin
procedure AttachChartSource(Pdf: TPdf; PageNumber: Integer;
  const CsvBytes: TBytes; const OutPath: string);
var
  Options: TAssocFilesOptions;
begin
  // Seit v3.122.0 wirft ein TargetPage außerhalb des Bereichs EPdfError (ältere Builds
  // fielen still auf ein /AF auf Katalog-Ebene zurück); vorher prüfen benennt die Seite
  if (PageNumber < 1) or (PageNumber > Pdf.PageCount) then
    raise EArgumentOutOfRangeException.CreateFmt('No page %d', [PageNumber]);

  Options := TAssocFilesOptions.Default;
  Options.TargetPage := PageNumber;
  SetLength(Options.Files, 1);
  Options.Files[0].FileName := 'chart-data.csv';
  Options.Files[0].Content := CsvBytes;
  Options.Files[0].Relationship := afSource;
  Options.Files[0].MIMEType := 'text/csv';

  if not Pdf.SaveAsWithAssociateFiles(OutPath, Options) then
    raise Exception.Create('Associated-file save failed');
end;

Wie lesen Sie AFRelationship zuverlässig zurück?

TPdf.AttachmentRelationship[Index] liefert den /AFRelationship-Namen einer Anhängung über den nativen Export FPDFAttachment_GetAFRelationship, aber ein leerer String hat zwei mögliche Bedeutungen, also rufen Sie zuerst AttachmentRelationshipFeaturesAvailable auf. Die Bindung wird tolerant geladen: Fehlt der DLL dieser Export, liest sich jede Relationship als leer, was von einer Dateispezifikation ohne /AFRelationship nicht zu unterscheiden ist. Die Property teilt sich ihren Index mit AttachmentCount, das die Einträge im /Names /EmbeddedFiles-Baum zählt. Der Injector schreibt nur die /AF-Kette und legt keinen Name-Tree-Eintrag an, also liegt eine über InjectAssociateFiles angehängte Datei außerhalb dieses Index; um die injizierte Kette zu bestätigen, inspizieren Sie die gespeicherten Bytes oder fahren einen PDF/A-Validator. Die Innereien dieses Name-Baums sind in Arbeiten mit PDF-Anhängungen in Delphi mit PDFium Component beschrieben

procedure ReportRelationships(const FileName: string);
var
  Pdf: TPdf;
  I: Integer;
  Rel: string;
begin
  if not AttachmentRelationshipFeaturesAvailable then
  begin
    Writeln('This PDFium build cannot report /AFRelationship');
    Exit;  // eine leere Antwort wäre mehrdeutig, also nicht fragen
  end;

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    for I := 0 to Pdf.AttachmentCount - 1 do
    begin
      Rel := Pdf.AttachmentRelationship[I];
      if Rel = '' then
        Rel := '(no /AFRelationship)';
      Writeln(Pdf.AttachmentName[I], ': ', Rel);
    end;
  finally
    Pdf.Free;
  end;
end;

Was garantiert SaveAsWithAssociateFiles nicht?

TPdf.SaveAsWithAssociateFiles garantiert den Dateiformat-Umschlag und dass die angeforderten Dateien injiziert wurden, nicht Konformität. Der Injektionsteil ist neu: Vor v3.122.0 kopierte InjectAssociateFiles die Eingabe unverändert durch und die Methode lieferte trotzdem True, wenn die gespeicherten Bytes keinen lesbaren Trailer hatten oder das Katalog-Dictionary nicht auffindbar war. Seit v3.122.0 wirft InjectAssociateFiles in diesen Fällen EPdfAssocFilesError, bevor irgendetwas geschrieben wird, SaveAsWithAssociateFiles liefert False, und weil es jetzt die komplette Ausgabe in einem Save-Store aufbaut, bevor es das Ziel öffnet, stutzt eine abgelehnte oder gescheiterte Speicherung keine existierende Datei mehr an. Ein leeres Files-Array kopiert das Dokument weiterhin by Design unverändert durch. Der Inhalt der Payload ist ebenfalls Ihre Verantwortung: Der Injector prüft weder, ob eine XML-Datei wohlgeformt ist, ob der MIME-Typ zu den Bytes passt, noch ob das Basisdokument überhaupt PDF/A ist. Behandeln Sie die fertige Datei als unverifiziert, bis ein Validator sie gesehen hat – dieselbe Disziplin wie in PDFium Component und PDF/A-Archiv-Konformität beschrieben. Wer eingehende Dictionaries selbst parst, wendet dieselben #XX-Namensregeln rückwärts an, ein Thema, das in Name-Token-Fallen beim Parsen von PDF-Dictionaries behandelt wird

Associated Files, PDF/A-Ausgaben, Attachment-Metadaten und Validierung kommen alle in derselben Komponente, also läuft die Pipeline oben ohne eine zweite PDF-Bibliothek im Build. API-Referenz, Trial-Download und Lizenzoptionen finden Sie auf der PDFium Component product page