Technischer Artikel

PDF/VCR-1-Vorlagenplatzhalter in Delphi mit PDFlibPas

PDFlibPas implementiert PDF/VCR-1 als getaggtes Strukturprofil: Die Ersetzungsdefinition lebt unter StructTreeRoot als direkter Kindzweig, wobei eine Vorlagenwurzel /O /GTS_Template trägt, um das Feldset zu deklarieren, und jeder Platzhalter /O /GTS_Replacement trägt, um ein Datenfeld zu binden. Nichts davon ist ein Seitenlevel-Dictionary

Genau dieser eine Satz ist der Teil, den die meisten Implementierungen falsch machen – und falsch machen kostet mehr als eine fehlgeschlagene Validierung. Ein Variable-Data-Job, dessen Ersetzungsplan falsch gelesen wird, crasht nicht. Er druckt in Serie mit dem falschen Feld in der falschen Box, und der Erste, der es merkt, ist der Kunde mit fünfzigtausend Mailern in der Hand

Wo die Ersetzungsdefinition tatsächlich lebt

PDF/VCR-1 legt sein Ersetzungsmodell in die in ISO 32000-1 §14.7 beschriebene logische Strukturhierarchie, nicht in ein privates Seiten-Dictionary. Die Vorlagenwurzel ist ein Strukturelement, das direkt an StructTreeRoot hängt, erkennbar an einem Attributbesitzer GTS_Template, und deklariert den gesamten Feldwortschatz an einer Stelle über GTS_Fields plus ein optionales GTS_Pages, das das Feld benennt, das die Seitenauswahl steuert

Platzhalter sind Strukturelemente, die in dieser Wurzel verschachtelt liegen, mit dem Attributbesitzer GTS_Replacement. Jeder trägt GTS_Generator (das PDFlibPas als PassThrough verlangt), einen GTS_Data-Wert, der genau ein deklariertes Feld benennt, und ein optionales GTS_BBox. Die Bindung an die Seite ist auch keine Seitenannotation: Das Element zeigt über /Pg auf seine Seite und über /K auf die Marked-Content-Sequenz. Die Wahl von Struktur statt Seiten-Dictionary hat dann eine Konsequenz, die leicht zu benennen und leicht zu unterschätzen ist: Der Strukturbaum ist ein allgemeiner Graph, den man vom Katalog aus erreicht, daher muss alles, was das Profil konsumiert, ein Baumläufer sein – mit allen Gefahren, die das mit sich bringt – statt ein Dictionaryleser. Wenn Sie bisher nicht mit dem Strukturbaum gearbeitet haben, sind die Mechanismen in getaggter PDF-Struktur und Barrierefreiheit ausgebreitet

Wo PDFlibPas das PDF/VCR-1-Ersetzungsmodell ablegt: Die Vorlagenwurzel hängt direkt an StructTreeRoot mit einem Attributbesitzer GTS_Template und deklariert GTS_Fields und das optionale GTS_Pages, während jeder darin verschachtelte Platzhalter GTS_Replacement, einen GTS_Generator von PassThrough, ein GTS_Data-Feld, ein optionales GTS_BBox trägt und über /Pg auf seine Seite und über /K auf seinen Inhalt zeigt
Die Ersetzungsdefinition lebt in der logischen Strukturhierarchie statt in einem Seiten-Dictionary, was jeden Konsumenten zum Baumläufer macht und das Element zu einem Graphknoten werden lässt, den ein Angreifer formen kann

Warum lässt das Prüfen nur des ersten Top-Level-Zweigs schlechte Dateien durch?

Weil StructTreeRoot viele direkte Kinder haben kann, und ein Validator, der nach dem Auffinden der Vorlagenwurzel stoppt, die übrigen nie sieht. Genau dieses Versagen ist PDFlibPas gebaut, um es zu vermeiden, und es erzeugt genau die Art von falschem Bestehen, die schlimmer ist als gar keine Prüfung: eine Datei, die konform meldet und eine zweite Vorlagenwurzel enthält, oder einen Platzhalter außerhalb des Vorlagenzweigs, den kein Konsument beachtet

Deshalb läuft ValidatePDFVCRStructure in PDFlibPas zweimal über denselben Baum. Der erste Durchlauf zählt alle direkten Kinder von StructTreeRoot, die GTS_Template tragen; alles andere als genau eines wird abgelehnt, bevor auch nur ein Platzhalter gelesen wird. Der zweite Durchlauf steigt in alle Top-Level-Zweige ab, nicht nur in den Vorlagenzweig, und lehnt zwei Dinge zugleich ab: ein verschachteltes Element mit GTS_Template, das nicht die Wurzel ist, und jedes GTS_Replacement-Element außerhalb des Vorlagenzweigs

Der Durchlauf ist bei 64 Ebenen Tiefe und 1.000.000 Knoten begrenzt, und er notiert Objekt- und Generationsnummer jedes besuchten Elements, sodass ein Zyklus oder eine wiederholte Referenz gemeldet statt verfolgt wird. Diese Zahlen sind kein Performance-Tuning. Ein Strukturbaum ist ein Objektgraph, den ein Angreifer kontrolliert, und ein Validator, der ohne Decke rekursiert, ist ein Stack Overflow mit einer Dateiendung

Zweipassige Validierung von ValidatePDFVCRStructure in PDFlibPas: Durchlauf eins zählt die direkten Kinder von StructTreeRoot mit GTS_Template und lehnt alles außer genau einem ab, Durchlauf zwei steigt in jeden Top-Level-Zweig ab, um eine verschachtelte Vorlagenwurzel und jedes GTS_Replacement außerhalb des Vorlagenzweigs abzulehnen, begrenzt auf 64 Ebenen und eine Million Knoten
Ein Validator, der nach dem Auffinden der ersten Vorlagenwurzel stoppt, sieht eine zweite nie, daher deckt der Lauf alle Top-Level-Zweige ab und notiert Objektnummern, sodass ein Zyklus gemeldet statt verfolgt wird

Eine Vorlage erstellen – und die Beschränkungen, an denen die API nicht rütteln lässt

PDFlibPas stellt das Erstellen über eine Klammer-API bereit, die die Strukturregeln beim Schreiben durchsetzt statt sie beim Speichern zu entdecken. SetPDFVCRMode schaltet das Dokument in das Profil, was intern den PDF/X-4-Basismodus von ISO 15930-7 voraussetzt und das Mark-Info-Flag einschaltet, denn eine PDF/VCR-1-Datei ohne Marked Content hat nichts, worauf ein Platzhalter zeigen könnte. Von dort nimmt BeginPDFVCRTemplate die Feldliste und das Seitenauswahlfeld entgegen, und es ist streng auf Weisen, die Sie kennen sollten, bevor Sie Ihr Datenschema entwerfen. Feldnamen laufen durch PDFVCRValidFieldName, das nur ASCII-Buchstaben, Ziffern, Unterstrich, Bindestrich und Punkt akzeptiert, denn das sind die Zeichen, die ohne Escape-Mehrdeutigkeit in einen PDF-Namen serialisieren. PDFVCRNormalizeFieldList hält die Liste case-sensitiv und lehnt Duplikate rundweg ab, und das Seitenauswahlfeld muss selbst in der deklarierten Liste auftauchen

var
  Lib: TPDFlib;
  Diag: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.NewDocument;

    if Lib.SetPDFVCRMode(1) <> 1 then
      raise Exception.Create('PDF/VCR mode was refused');

    Lib.NewPage;
    if Lib.BeginPDFVCRTemplate('Salutation,FullName,AccountNo',
                               'AccountNo') <> 1 then
      raise Exception.Create('Field list rejected');

    // Ein Platzhalter bindet genau ein deklariertes Feld.
    Lib.BeginPDFVCRPlaceholder('FullName', 72, 690, 320, 712);
    Lib.DrawText(72, 694, 'SAMPLE NAME');
    Lib.EndPDFVCRPlaceholder;

    Lib.EndPDFVCRTemplate;

    Diag := Lib.GetPDFVCRDiagnostics;
    if Diag <> '' then
    begin
      Writeln('PDF/VCR blockers: ', Diag);
      Exit;
    end;

    Lib.SaveToFile('campaign-template.pdf');
  finally
    Lib.Free;
  end;
end;

Beachten Sie, was Ihnen die Klammerung bringt. BeginPDFVCRPlaceholder weigert sich zu öffnen, solange keine Vorlage aktiv ist und der Tag-Stack nicht genau eine Ebene tief ist – so garantiert PDFlibPas, dass Platzhalter direkte Kinder der Vorlagenwurzel sind und nie ineinander verschachteln. Auch Koordinaten werden validiert: NaN, Unendlich und invertierte Rechtecke werden abgelehnt, sodass eine schlechte Bounding Box beim Aufruf scheitert statt in einem Preflight-Bericht eine Woche später

Warum braucht ein Platzhalter einen eigenen MCID-Serialisierungspfad?

Weil PDF/VCR-1 /K als nackte Ganzzahl will – und so serialisiert ein allgemeiner Tagged-PDF-Writer die Verbindung zwischen Strukturelement und Inhalt nicht. Nach ISO 32000-1 §14.6 referenziert ein Strukturelement Marked Content normalerweise entweder über eine direkte MCID-Ganzzahl, wenn das Element seine Seite bereits über /Pg identifiziert, oder über ein Marked-Content-Referenzdictionary, das die Seite explizit benennt. Eine Bibliothek, die für viele Zwecke getaggte Ausgabe erzeugt, neigt dazu, sich auf die Dictionary-Form zu vereinheitlichen, weil sie sich über Seiten hinweg besser komponiert

PDFlibPas hat hier die schmale Option gewählt statt der allgemeinen. SetCurrentStructElemDirectMCIDKid markiert das aktuelle Strukturelement, und nur Elemente mit diesem Flag serialisieren /K als direkte MCID mit der Seite auf /Pg; alles andere lässt den bestehenden MCR-Dictionary-Pfad unangetastet. Das ist ein bewusster Trade-off, und er ist einer, den man laut benennen sollte: Ein Profil hinzuzufügen darf die Bytes der Ausgabe nicht ändern, die jedes andere getaggte Dokument bereits erzeugt, daher bekommt das Profil einen eigenen Pfad statt eines globalen Schalters. Jede bestehende getaggte Datei behält ihre bisherige Serialisierung, und die Regressionsfläche bleibt so groß wie das neue Feature

Ein Durchlauf, zwei Konsumenten

PDFlibPas leitet das Ersetzungsmanifest und das Konformitätsurteil aus demselben Zweipass-Lauf ab – deshalb darf man dem Manifest als Jobplan vertrauen. Der Durchlauf füllt während der Validierung einen TPDFVCRManifestData-Record: Fields von der Vorlagenwurzel, PageSelectionField, wenn eines deklariert ist, und ein Placeholders-Array aus TPDFVCRPlaceholderInfo-Einträgen mit FieldName, PageNumber, MCID, HasBBox und BBox. Der Konformitätseinstieg ist CheckCompliancePDFVCR(InputStream, Password, Options, ResultList), und er führt seine Prüfungen in Abhängigkeitsreihenfolge aus statt auf einmal: zuerst die volle PDF/X-Prüfung, dann GTS_PDFXVersion gegen PDF/X-4, dann GTS_PDFVCRVersion aus dem Namespace http://www.npes.org/pdfvcr/ns/id/, und erst dann der Strukturlauf. Options als 1 übergeben lässt ihn beim ersten Problem stoppen – das will man in einem Batch-Gate; 0 übergeben sammelt alles – das will man in einem Bericht

Ein Durchlauf, zwei Konsumenten in PDFlibPas: Derselbe begrenzte Lauf füllt TPDFVCRManifestData mit Fields, PageSelectionField und einem Placeholders-Array aus TPDFVCRPlaceholderInfo-Einträgen, während CheckCompliancePDFVCR die volle PDF/X-Prüfung, GTS_PDFXVersion, GTS_PDFVCRVersion aus dem npes.org-Namespace und erst dann den Strukturlauf ausführt und nur 100 zurückgibt, wenn die Datei qualifiziert ist
Das Manifest ist ein Vertrag, daher gibt BuildPDFVCRManifest JSON nur zurück, wenn die Problemliste leer bleibt, und meldet sonst Fehler 608 – so kann sich eine nicht konforme Datei nicht als vertrauenswürdiger Jobplan ausgeben
// Eine Vorlage prüfen, die Sie erhalten statt erzeugt haben
var
  Issues: TStringList;
  Stream: TFileStream;
  I: Integer;
begin
  Issues := TStringList.Create;
  Stream := TFileStream.Create('supplier-template.pdf',
                               fmOpenRead or fmShareDenyWrite);
  try
    if CheckCompliancePDFVCR(Stream, '', 0, Issues) <> 100 then
      for I := 0 to Issues.Count - 1 do
        Writeln('PDF/VCR: ', Issues[I]);
  finally
    Stream.Free;
    Issues.Free;
  end;
end;

Das Manifest ist ein Vertrag, daher existiert es nur für konforme Dateien

BuildPDFVCRManifest gibt JSON nur zurück, wenn die Problemliste leer bleibt, sonst einen leeren String. Das ist keine Defensivprogrammierung um ihrer selbst willen. Ein Manifest wird von einer Kompositionsengine konsumiert, die echte Ausgabe daraus steuert, und eine partielle Feldliste aus einer Datei mit zwei Vorlagenwurzeln einzuhändigen, lädt die Engine ein, eine nicht konforme Struktur als vertrauenswürdigen Ersetzungsplan zu behandeln. Über die Dokument-API verpackt GetPDFVCRManifest(InputFileName, Password) denselben Aufruf und setzt den letzten Fehlercode auf PDFLIB_ERROR_PDFVCR_COMPLIANCE (608), wenn die Datei nicht qualifiziert ist, sodass eine fehlgeschlagene Extraktion von einer leeren Vorlage unterscheidbar ist. Das JSON meldet fields, pageSelectionField als String oder null und ein placeholders-Array, dessen Einträge field, page, mcid und entweder eine vierteilige bbox oder null angeben

var
  Lib: TPDFlib;
  Manifest: WideString;
begin
  Lib := TPDFlib.Create;
  try
    Manifest := Lib.GetPDFVCRManifest('campaign-template.pdf', '');
    if Manifest = '' then
      // 608 heißt: Die Datei ist nicht PDF/VCR-1-konform,
      // 401 heißt: Die Datei konnte nicht geöffnet werden
      Writeln('No manifest, error ', Lib.LastErrorCode)
    else
      Writeln(Manifest);
  finally
    Lib.Free;
  end;
end;

Ein Detail, das Sie in Ihren Konsumenten mitnehmen sollten: Der page-Wert folgt der bibliotheksweiten nullbasierten Seitenkonvention, nicht der einbasierten Nummerierung, die ein Mensch im Viewer sieht. Das zu verwechseln ist ein stiller Off-by-One, der Inhalt auf das falsche Blatt bringt – und kein Validator fängt es, denn beide Zahlen sind strukturell gültig

Wo die Grenzen liegen

Die PDF/VCR-1-Unterstützung in PDFlibPas deckt Vorlagenerstellung, Save-Gate, eigenständiges Preflight und Manifestextraktion ab. Sie führt die Ersetzung nicht aus. Daten in Platzhalter einzusetzen und die personalisierten Exemplare zu erzeugen, ist der Kompositionsschritt, und er lebt mit Absicht außerhalb der Bibliothek, denn das Manifest ist genau die Schnittstelle, die es erlaubt, eine Kompositionsengine unabhängig zu schreiben. Auch das Save-Gate verweigert statt zu reparieren. Anders als bei Profilen, in denen ein falsches Annotationsflag stillschweigend normalisiert werden kann, ist jede von PDFVCRReadyForSave durchgesetzte PDF/VCR-1-Beschränkung eine, bei der Raten die Bedeutung des Dokuments ändern würde: Eine Vorlagenwurzel zu erfinden oder zu raten, zu welchem Feld ein ungebundener Platzhalter gehört, erzeugt eine Datei, die validiert und lügt. Dieselbe Reparieren-gegen-Verweigern-Linie ist für ein anderes Profil im Artikel PDF/E-1-Autorenmodus und begrenztes Preflight besprochen, und der Workflow für mehrere Konformitätsprüfungen über ein Dokument ist in der Compliance- und Signing-Workbench beschrieben

Erstellung, der begrenzte Strukturlauf, das Save-Gate und der Manifestextraktor gehören alle zur PDFlibPas-Delphi-PDF-Bibliothek, was bedeutet: Eine Vorlage kann unter dem Profil erzeugt und dann über einen getrennten Codepfad verifiziert werden, der den Writer-Zustand nie gesehen hat – und genau diese Trennung ist der einzige Grund, warum eine Konformitätsaussage etwas wert ist