Technischer Artikel

Validieren komprimierter PDFs: Objekt- und XRef-Streams

Sie schreiben einen kleinen Validator. Er öffnet ein PDF, springt zum Ende, findet startxref, liest den Offset und erwartet, auf dem Schlüsselwort xref mit einer darunterliegenden Querverweistabelle (Cross-Reference Table) fester Breite zu landen. Aus dieser Tabelle sammelt er Objektoffsets und sucht dann rückwärts nach dem Schlüsselwort trailer, um /Root und /Size in Erfahrung zu bringen. Das funktioniert bei jeder Datei, die Sie zum Testen generiert haben, perfekt. Dann kommt eine Datei, die von einer aktuellen Word-Version oder einer Bibliothek erstellt wurde, die auf PDF 1.5 abzielt, und der Validator erklärt sie für fehlerhaft. Dort, worauf der Offset zeigt, gibt es kein Schlüsselwort xref, nirgendwo ein trailer-Wörterbuch, und die Objekttabelle, die der Validator aufgebaut hat, ist fast leer. Die Datei ist gültig. Der Validator betrachtet sie nur durch eine fünfzehn Jahre alte Linse

Dies ist der mit Abstand häufigste Grund, warum eine auf Byte-Ebene geschriebene PDF-Prüfung, die für das klassische Layout entwickelt wurde, bei modernen Dokumenten fehlschlä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 (Cross-Reference Stream) und der komprimierte Objekt-Stream (Object Stream). Beide sind in ISO 32000-1 beschrieben, und ein Validator, der sie nicht kennt, sieht eine intakte Datei als einen Haufen fehlender Objekte

Was PDF 1.5 am Dateiende geändert hat

ISO 32000-1 §7.5.8 definiert den Querverweis-Stream, und §7.5.7 definiert den Objekt-Stream vom Typ /ObjStm. Zusammen ermöglichen sie es einem Writer, auf die beiden Strukturen zu verzichten, auf die sich ein klassischer Parser stützt. Eine PDF-1.5-Datei kann ganz ohne xref-Tabelle enden. Stattdessen ist das Objekt, auf das startxref zeigt, ein gewöhnliches Stream-Objekt, dessen Wörterbuch /Type /XRef enthält, und dieser Stream hält die Querverweisdaten in einer kompakten binären Form. Es gibt auch kein Schlüsselwort trailer mehr, da der Trailer nun das eigene Wörterbuch des Streams ist. Die Schlüssel, nach denen ein klassischer Parser gesucht hat, /Root, /Size und /ID, befinden sich innerhalb dieses Wörterbuchs

Die zweite Änderung verschiebt die Objekte selbst. Anstatt jedes indirekte Objekt an seinem eigenen Byte-Offset zu schreiben, kann ein Writer viele kleine Objekte – die Seitenwörterbücher, die Anmerkungswörterbücher, den Strukturbaum – in einen einzigen Objekt-Stream packen und den gesamten Container mit Flate komprimieren. Die einzelnen Objekte haben keinen Byte-Offset 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 nie, da dieser Text erst nach der Dekomprimierung (Inflation) existiert. Für einen klassischen Parser ist die Hälfte des Dokuments einfach verschwunden

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

Das Beruhigende ist, dass das Lesen des Trailers eines Querverweis-Streams keine Dekomprimierung erfordert. Ein Stream-Objekt wird als Wörterbuch geschrieben, gefolgt vom Schlüsselwort stream und dann den komprimierten Bytes. Das Wörterbuch ist Klartext. Wenn also startxref auf einen Querverweis-Stream zeigt, sehen die Bytes unmittelbar nach der Objektnummer wie ein gewöhnliches Wörterbuch aus, und /Root, /Size und /ID stehen dort im Klartext, bevor das Schlüsselwort stream und die Flate-Daten beginnen

Das bedeutet, dass ein Validator die drei Fakten, die er am meisten benötigt – wo sich der Katalog befindet, wie viele Objekte die Datei beansprucht und den Datei-Identifikator – allein durch das Parsen des Stream-Wörterbuchs in Erfahrung bringen kann. Er muss die Querverweisdaten nicht dekomprimieren und die darin enthaltenen binären Einträge nicht interpretieren. Die Arbeit, die einen naiven Parser scheitern lässt, besteht nicht im Lesen des Trailers, sondern im Finden der Objekte. Das sind zwei trennbare Probleme, und die Lösung des ersten ist billig

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

Ein Objekt-Stream ist ein Container. Sein Wörterbuch enthält /Type /ObjStm, einen /N-Eintrag, der die Anzahl der darin gepackten Objekte angibt, und einen /First-Eintrag, der den Byte-Offset innerhalb der dekomprimierten Daten angibt, an dem der Rumpf des ersten Objekts beginnt. Die komprimierte Nutzlast (Payload) beginnt nach der Dekomprimierung mit einem kleinen Header aus /N Ganzzahl-Paaren. Jedes Paar besteht aus einer Objektnummer und dem Offset des Rumpfs dieses Objekts relativ zu /First. Nach dem Header folgen die Objekt-Rümpfe selbst, aneinandergehängt

Die Erweiterung eines solchen Streams ist mechanisch, sobald die Bytes dekomprimiert sind. Sie lesen das Wörterbuch, um /N und /First zu erhalten, dekomprimieren den Stream mit einem Flate-Decoder, gehen die führenden /N-Paare durch, um zu erfahren, welche Objektnummer an welchem Offset lebt, und heben dann jeden Rumpf heraus, als wäre es ein gewöhnliches indirektes Objekt. Die einzige echte Abhängigkeit ist der Flate-Decoder, und den haben Sie bereits: Delphi liefert System.ZLib mit, und Free Pascal liefert die Unit zstream, die beide zlib verpacken 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 durch /Root geht und den Seitenbaum (Page Tree) 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 nicht die Dekodierung der binären Einträge des Querverweis-Streams. Der Querverweis-Stream nach §7.5.8 verwendet drei Eintragstypen, und der Typ-2-Eintrag – derjenige, der besagt: dieses Objekt lebt innerhalb des Objekt-Streams N am Index i – ist das, was Sie dekodieren würden, um eine vollständige Offset-Map zu erstellen. Sie benötigen diese Map, um beliebige Objekte anhand ihrer Nummer aufzulösen. Sie benötigen sie nicht, um /Root, /Size und /ID zu lesen, die sich im Klartext-Wörterbuch befinden, und Sie benötigen sie nicht, um Objekt-Streams zu erweitern, da jedes /ObjStm seine eigenen Inhalte durch /N und /First ankündigt

Sie müssen sich auch nicht um die PNG- und TIFF-Prädiktorfunktionen kümmern, die ein Querverweis-Stream möglicherweise über seine /DecodeParms anwendet, 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 Wörterbuch zu tun, das dem Stream vorausgeht. Das minimale Upgrade, das einen klassischen Validator fit für moderne PDFs macht, ist daher klein: Wenn startxref auf einem Stream und nicht auf dem Schlüsselwort xref landet, parsen Sie das Stream-Wörterbuch nach den Trailer-Schlüsseln und erweitern Sie alle /ObjStm-Objekte, auf die Sie stoßen, damit ihre Inhalte in die Objekttabelle gelangen. Das Dekodieren von Typ-2-Einträgen und Prädiktoren ist eine separate, größere Aufgabe, die Sie aufschieben können, bis Sie tatsächlich eine zufällige Objektauflösung benötigen

Warum eine Compliance-Prüfung Streams zuerst erweitern muss

Dies hört in dem Moment auf, rein akademisch zu sein, in dem Sie eine Profilprüfung durchführen. Ein PDF/A- oder PDF/X-Validator inspiziert bestimmte Objekte: den Dokumentenkatalog auf ein /OutputIntents-Array, den /Metadata-Stream auf ein XMP-Paket mit der richtigen Kennung, jeden Font-Deskriptor auf eine eingebettete Schriftartdatei, den Trailer auf eine /ID. In einer komprimierten Datei befinden sich die meisten dieser Objekte innerhalb von Objekt-Streams. Ein Validator, der die Objekt-Streams nicht erweitert hat, kann die Schlüssel des Katalogs nicht sehen, die Metadaten nicht finden und die Schriftarten nicht aufzählen. Er wird ein perfekt konformes Dokument so melden, als fehle sein Output-Intent, sein XMP und die Hälfte seiner Struktur, weil die Beweise, die er benötigt, immer noch in einem Flate-Blob sitzen, den er nie dekomprimiert hat

Die Reihenfolge ist wichtig. Die Erweiterung muss vor den Prüfungen erfolgen, nicht parallel dazu, da jede Prüfung davon ausgeht, dass sie ein Objekt anhand seiner Nummer erreichen kann. Wenn Sie eine Profilprüfung direkt mit einem rohen Byte-Scan verknüpfen, erbt sie die Blindheit des klassischen Parsers und produziert falsche Verstöße genau bei jenen modernen Dateien, die am ehesten wohlgeformt sind, da sie aus Toolchains stammen, die neu genug sind, um überhaupt erst Querverweis-Streams zu schreiben

PDFium das Parsen überlassen

Die PDFium-Komponente parst Querverweis-Streams und Objekt-Streams als Teil des Ladens eines Dokuments. Das ist der praktische Weg, um zu vermeiden, den Inflate-und-Expand-Schritt selbst zu programmieren. Wenn Sie eine Datei mit der TPdf-Komponente laden, sind die in /ObjStm-Containern gepackten Objekte bereits aufgelöst, und die Einstiegspunkte für die Validierung sehen das vollständig erweiterte Dokument. ValidatePdfA gibt einen TPdfAValidationResult-Record zurück, dessen Feld Conformance ein TPdfAConformance-Wert wie pac1b oder pacNone ist, dessen Feld Issues eine Menge (Set) der spezifischen gefundenen Probleme darstellt und dessen Methode IsCompliant nur dann wahr ist, wenn eine Konformitätsstufe (Conformance Level) erkannt wurde und die Issue-Menge leer ist. Da die Objekte beim Laden erweitert 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;

Das Gleiche gilt für ValidatePdfX, das ein TPdfXValidationResult in derselben Form zurückgibt. Der Sinn des Routings über PDFium ist, dass die oben beschriebene strukturelle Dekomprimierung einmalig und korrekt innerhalb des Loaders stattfindet, sodass Ihr Validierungscode nie den Unterschied zwischen einer klassischen Datei und einer vollständig komprimierten Datei sieht. Beide kommen beim Validator als eine aufgelöste Menge von Objekten an

function PdfXConformanceName(C: TPdfXConformance): string;
begin
  case C of
    pxc1a: Result := 'PDF/X-1a';
    pxc3 : Result := 'PDF/X-3';
    pxc4 : Result := 'PDF/X-4';
  else
    Result := 'none';
  end;
end;

var
  Pdf: TPdf;
  R  : TPdfXValidationResult;
  Issue: TPdfXValidationIssue;
  IssueCount: Integer;
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: ', PdfXConformanceName(R.Conformance))
    else
    begin
      IssueCount := 0;
      for Issue in R.Issues do   // Issues is a set: count its members
        Inc(IssueCount);
      Writeln('Not conformant; issue count = ', IssueCount);
    end;
  finally
    Pdf.Free;
  end;
end;

Wenn die Bytes bereits im Speicher und nicht auf der Festplatte liegen, funktioniert dieselbe Load-then-Validate-Sequenz über die Überladung LoadDocument(const Data: TBytes), die den rohen Dateiinhalt entgegennimmt und dessen Querverweis- und Objekt-Streams auf die gleiche Weise parst wie der Dateipfad. Die wichtigste Erkenntnis für einen handgeschriebenen Validator ist die strukturelle Regel, nicht die API: Lesen Sie die Trailer-Schlüssel aus dem Stream-Wörterbuch im Klartext, erweitern Sie jedes /ObjStm mit einem Flate-Decoder, bevor Sie das Dokument durchgehen, und behandeln Sie die Dekodierung der binären Querverweiseinträge als die größere, optionale Aufgabe, die sie ist

Sobald die Struktur erweitert ist, kann ein Validator den Rest eines Workflows darüber steuern. Für einen Kommandozeilen-Preflight-Harness, der die Konformität über einen Ordner von Eingaben hinweg meldet, lesen Sie unsere Anleitung zum Erstellen einer Batch-Preflight-Berichts-CLI. Wenn die Validierung ein Tor ist, bevor ein großes Dokument in Teile zerlegt wird, passen die Techniken in unserem Leitfaden zum Aufteilen von PDF-Dokumenten in mehrere Dateien natürlich zu dem hier gezeigten Load-and-Check-Muster. Beide bauen auf der Lade- und Validierungsoberfläche der PDFium-Komponente für Delphi und C++Builder auf