Technical Article

Validierung komprimierter PDFs: Objekt- und XRef-Streams

Sie schreiben einen kleinen Validator. Er öffnet eine PDF-Datei, sucht nach dem Ende, findet startxref, liest den Versatz und erwartet, auf das Schlüsselwort xref zu stoßen, unter dem sich eine Querverweistabelle mit fester Breite befindet. Aus dieser Tabelle sammelt er Objektoffsets und sucht dann rückwärts nach dem Schlüsselwort trailer, um die Werte für /Root und /Size zu ermitteln. Das funktioniert bei jeder von Ihnen zum Testen erstellten Datei perfekt. Dann trifft eine Datei ein, die von einer aktuellen Word-Version oder einer Bibliothek für PDF 1.5 erstellt wurde, und der Validator erklärt sie für beschädigt. Es gibt kein Schlüsselwort xref an der Stelle, auf die der Versatz zeigt, kein trailer-Dictionary weit und breit, und die vom Validator erstellte Objekttabelle ist fast leer. Die Datei ist gültig. Der Validator liest sie nur durch eine fünfzehn Jahre alte Brille.

Dies ist der häufigste Grund, warum eine auf Byteebene geschriebene PDF-Prüfung, die gegen das klassische Layout entwickelt wurde, bei modernen Dokumenten fehschlägt. Die Struktur, von der sie abhängt – die Klartext-Querverweistabelle und das Schlüsselwort trailer –, wurde in PDF 1.5 optional gemacht und fehlt häufig. Zwei Funktionen haben sie ersetzt: der Querverweis-Stream (XRef Stream) und der komprimierte Objekt-Stream. Beide sind in ISO 32000-1 beschrieben, und ein Validator, der sie nicht kennt, sieht eine gesunde Datei als einen Haufen fehlender Objekte.

Was PDF 1.5 am Ende der Datei geändert hat

ISO 32000-1 §7.5.8 definiert den Querverweis-Stream, und §7.5.7 definiert den Objekt-Stream des Typs /ObjStm. Zusammen ermöglicht sie es einem Writer, die beiden Strukturen wegzulassen, auf die sich ein klassischer Parser stützt. Eine PDF-1.5-Datei kann völlig ohne xref-Tabelle enden. An ihrer Stelle ist das Objekt, auf das startxref zeigt, ein gewöhnliches Stream-Objekt, dessen Dictionary den Typ /Type /XRef trägt, und dieser Stream enthält die Querverweisdaten in einer kompakten binären Form. Es gibt auch kein Schlüsselwort trailer mehr, da der Trailer nun das eigene Dictionary des Streams ist. Die Schlüssel, nach denen ein klassischer Parser gesucht hat – /Root, /Size und /ID –, befinden sich in diesem Dictionary.

Die zweite Änderung betrifft die Objekte selbst. Anstatt jedes indirekte Objekt an seinem eigenen Byte-Versatz zu schreiben, kann ein Writer viele kleine Objekte – die Seiten-Dictionaries, die Annotations-Dictionaries, den Strukturbaum – in einen einzigen Objekt-Stream packen und den gesamten Container mit Flate komprimieren. Die einzelnen Objekte haben keinen Byte-Versatz mehr in der Datei. Sie haben eine Position innerhalb eines komprimierten Blobs. Ein Validator, der die rohen Bytes nach 1 0 obj durchsucht, findet sie niemals, da dieser Text erst nach dem Entkomprimieren existiert. Für einen klassischen Parser ist das halbe Dokument einfach verschwunden.

Die Trailer-Schlüssel sind Klartext, selbst in einer komprimierten Datei

Der beruhigende Teil ist, dass das Lesen des Trailers eines Querverweis-Streams kein Dekomprimieren erfordert. Ein Stream-Objekt wird als Dictionary geschrieben, gefolgt vom Schlüsselwort stream und den komprimierten Bytes. Das Dictionary ist Klartext. Wenn also startxref auf einen Querverweis-Stream zeigt, sehen die Bytes direkt nach der Objektnummer wie ein ganz normales Dictionary aus, und /Root, /Size und /ID stehen dort im Klartext, noch bevor das Schlüsselwort stream und die Flate-Daten beginnen.

Objekt-Streams: Ein Header, dann ein Flate-Blob

Ein Objekt-Stream ist ein Container. Sein Dictionary enthält /Type /ObjStm, einen /N-Eintrag, der die Anzahl der darin verpackten Objekte angibt, und einen /First-Eintrag für den Byte-Versatz innerhalb der entpackten Daten, an dem der Körper des ersten Objekts beginnt. Die komprimierten Nutzdaten beginnen nach dem Entpacken mit einem kleinen Header aus /N Ganzzahlpaaren. Jedes Paar besteht aus einer Obejtnummer und dem Versatz des Objektkörpers relativ zu /First. Nach dem Header folgen die Objektkörper selbst, aneinandergereiht.

Das Entpacken ist ein mechanischer Vorgang, sobald die Bytes dekomprimiert sind. Sie lesen das Dictionary aus, um /N und /First zu erhalten, dekomprimieren den Stream mit einem Flate-Decoder, durchlaufen die führenden /N Paare, um zu erfahren, welche Objektnummer an welchem Versatz liegt, und extrahieren dann jeden Körper, als wäre er ein gewöhnliches indirektes Objekt. Die einzige echte Abhängigkeit ist der Flate-Decoder, und Sie haben bereits einen: Delphi liefert System.ZLib und Free Pascal die Unit zstream, die beide zlib kapseln und einen rohen Flate-Stream ohne Drittanbieter-Code dekomprimieren. Eine Routine, die jedes extrahierte Objekt an die Objekttabelle des Validators anhängt, sorgt dafür, dass sich der Rest des Validators – der Teil, der /Root durchläuft und den Seitenbaum prüft – genau so verhält wie bei einer klassischen Datei.

Was Sie nicht implementieren müssen

Es ist leicht, den Aufwand zu überschätzen. Das Lesen der Trailer-Schlüssel aus einer komprimierten Datei erfordert keine Dekodierung der binären Einträge des Querverweis-Streams. Der in §7.5.8 beschriebene Querverweis-Stream verwendet drei Eintragstypen, und der Typ-2-Eintrag – der besagt, dass dieses Objekt im Objekt-Stream N am Index i liegt – ist das, was Sie dekodieren müssten, um eine vollständige Offset-Tabelle zu erstellen. Sie benötigen diese Tabelle, um beliebige Objekte nach ihrer Nummer aufzulösen. Sie benötigen sie nicht, um /Root, /Size und /ID zu lesen, die sich im Klartext-Dictionary befinden, und Sie benötigen sie nicht, um Objekt-Streams zu entpacken, da jeder /ObjStm seine eigenen Inhalte über /N und /First ankündigt.

Sie müssen auch nicht die PNG- und TIFF-Prädiktorfunktionen verarbeiten, die ein Querverweis-Stream über seine /DecodeParms anwenden kann, nur um die Trailer-Schlüssel zu erhalten. Prädiktoren filtern die binären Querverweiszeilen, damit sie sich besser komprimieren lassen; sie haben nichts mit dem Dictionary zu tun, das dem Stream vorausgeht. Das minimale Upgrade, das einen klassischen Validator für modernes PDF fit macht, ist daher klein: Wenn startxref auf einem Stream statt auf dem Schlüsselwort xref landet, parsen Sie das Stream-Dictionary nach den Trailer-Schlüsseln und entpacken Sie alle gefundenen /ObjStm-Objekte, damit deren Inhalte in die Objekttabelle einfließen. Das Dekodieren von Typ-2-Einträgen und Prädiktoren ist eine separate, größere Aufgabe, die Sie aufschieben können, bis Sie wirklich eine wahlfreie Objektauflösung benötigen.

Warum eine Compliance-Prüfung Streams zuerst entpacken muss

Dies ist keineswegs akademisch, sobald Sie eine Profilprüfung ausführen. Ein PDF/A- oder PDF/X-Validator prüft spezifische Objekte: den Dokumentenkatalog nach einem /OutputIntents-Array, den /Metadata-Stream nach einem XMP-Paket mit der richtigen Kennung, jeden Schriftart-Deskriptor nach einer eingebetteten Schriftdatei, den Trailer nach einer /ID. In einer komprimierten Datei befinden sich die meisten dieser Objekte in Objekt-Streams. Ein Validator, der die Objekt-Streams nicht entpackt hat, kann die Schlüssel des Katalogs nicht sehen, die Metadaten nicht finden und die Schriftarten nicht auflisten. Er meldet ein absolut konformes Dokument als fehlende Ausgabeabsicht (Output Intent), fehlendes XMP und fehlende Hälfte seiner Struktur, da die Beweise, die er benötigt, noch in einem Flate-Blob liegen, den er nie entpackt hat.

Die Reihenfolge ist wichtig. Das Entpacken muss stattfinden, bevor die Prüfungen laufen, nicht parallel dazu, da jede Prüfung davon ausgeht, ein Objekt über seine Nummer erreichen zu können. Wenn Sie eine Profilprüfung direkt an einen rohen Byte-Scan koppeln, erbt diese die Blindheit des klassischen Parsers und erzeugt Fehlalarme bei genau den modernen Dateien, die mit größter Wahrscheinlichkeit korrekt strukturiert sind – da sie aus Toolchains stammen, die neu genug sind, um Querverweis-Streams überhaupt erst zu schreiben.

PDFium das Parsen für Sie überlassen

Die PDFium Component parst Querverweis- und Objekt-Streams als Teil des Ladevorgangs eines Dokuments, was der praktische Weg ist, um das manuelle Entpacken und Entfalten zu vermeiden. Wenn Sie eine Datei mit der Komponente TPdf laden, sind die in /ObjStm-Containern verpackten Objekte bereits aufgelöste, und die Validierungs-Einstiegspunkte sehen das vollständig entfaltete Dokument. ValidatePdfA gibt einen TPdfAValidationResult-Datensatz zurück, dessen Feld Conformance ein TPdfAConformance-Wert wie pac1b oder pacNone ist, dessen Feld Issues eine Menge der spezifischen gefundenen Probleme ist und dessen Methode IsCompliant nur dann wahr ist, wenn eine Konformitätsstufe erkannt wurde und die Mängelmenge leer ist. Da die Objekte beim Laden entfaltet wurden, wird ein /OutputIntents-Array oder eine eingebettete Schriftart, die sich in einem Objekt-Stream befand, gefunden und nicht als fehlend gemeldet.

uses
  PDFium, FPdfPdfa;

function CheckPdfA(const FileName: string): TPdfAValidationResult;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;            // parses xref/object streams on load
    Result := Pdf.ValidatePdfA;    // sees the expanded object table
  finally
    Pdf.Free;
  end;
end;

Dasselbe gilt für ValidatePdfX, das ein TPdfXValidationResult mit derselben Struktur zurückgibt. Der Vorteil des Wegs über PDFium besteht darin, dass die oben beschriebene strukturelle Dekomprimierung einmal korrekt im Loader stattfindet, sodass Ihr Validierungscode niemals den Unterschied zwischen einer klassischen und einer vollständig komprimierten Datei sieht. Beide kommen beim Validator als aufgelöste Objektmenge an.

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'Press_Ready.pdf';
    Pdf.Active := True;
    R := Pdf.ValidatePdfX;
    if R.IsCompliant then
      Writeln('PDF/X conformance: ', Ord(R.Conformance))
    else
      Writeln('Not conformant; issue count = ', SizeOf(R.Issues));
  finally
    Pdf.Free;
  end;
end;

Wenn sich die Bytes bereits im Speicher statt auf der Festplatte befinden, funktioniert dieselbe Lade- und Validierungssequenz über die Überladung LoadDocument(const Data: TBytes), die den rohen Inhalt der Datei entgegennimmt und deren Querverweis- und Objekt-Streams auf dieselbe Weise parst wie der Dateipfad. Die wichtigste Lehre für einen selbst geschriebenen Validator ist die strukturelle Regel und nicht die API: Lesen Sie die Trailer-Schlüssel aus dem Stream-Dictionary im Klartext, entpacken Sie jedes /ObjStm-Objekt mit einem Flate-Decoder, bevor Sie das Dokument durchlaufen, und behandeln Sie das Dekodieren der binären Querverweiseinträge als die größere, optionale Aufgabe, die sie ist.

Sobald die Struktur entfaltet ist, kann ein Validator den restlichen Arbeitsablauf darauf aufbauen. Für eine Befehlszeilen-Preflight-Umgebung, die die Konformität über einen Ordner von Eingaben hinweg meldet, lesen Sie unseren Leitfaden zum Erstellen einer Batch-Preflight-Bericht-CLI. Wenn die Validierung ein Kontrollpunkt vor dem Aufteilen eines großen Dokuments ist, lassen sich die Techniken in unserer Anleitung zum Aufteilen von PDF-Dokumenten in mehrere Dateien hervorragend mit dem hier gezeigten Lade- und Prüfmuster kombinieren. Beide basieren auf der Lade- und Validierungsoberfläche der PDFium-Komponente für Delphi und C++Builder.