PDF-1.5-Object-Streams packen viele kleine indirekte Objekte in einen einzigen Flate-komprimierten Container, und die losLab PDF Library erzeugt sie bei einem vollständigen Save über ihr PackObjectStreams-Flag. Der Gewinn ist real: Hunderte von Seiten-, Schrift- und Annotations-Dictionaries, von denen jedes unkomprimiert Dutzende Bytes kostet, schrumpfen zu einer Handvoll komprimierter Blobs. Der Preis ist, dass nun jedes gepackte Objekt einen Cross-Reference-Stream benötigt, der es beschreibt
Genau diese zweite Hälfte bringt Writer zu Fall. Einen /ObjStm-Container zu bauen ist Arithmetik; der Cross-Reference-Maschinerie beizubringen, hinein zu zeigen, ist ein Redesign. Ein Writer, der einen tadellos gültigen Container erzeugt und dessen Mitglieder dann mit gewöhnlichen Typ-1-Offsets beschreibt, hat eine Datei produziert, die Acrobat gerade lange genug öffnet, um sie für beschädigt zu erklären. Die beiden Features sind ein Feature, und dieser Artikel behandelt die Schreibseite von beiden, wie in ISO 32000-1 §7.5.7 und §7.5.8 definiert
Was ein ObjStm-Container tatsächlich enthält
Ein Object Stream ist ein Stream, dessen dekodierte Bytes zwei aneinandergereihte Regionen bilden, und ISO 32000-1 §7.5.7 gibt dem Dictionary genau drei für die Konstruktion relevante Schlüssel. /Type /ObjStm identifiziert ihn, /N gibt die Anzahl der Mitglieder an, und /First gibt die Byte-Länge der Header-Region – äquivalent dazu den Offset, an dem der Body beginnt. Der Header besteht aus durch Whitespace getrennten Paaren von Objektnummer und Offset; der Body sind die Mitglieder, hintereinander serialisiert, wobei jeder Offset vom Anfang des Body gemessen wird, nicht vom Anfang der dekodierten Nutzlast. Liest man einen vollständig dekodierten Container, wird es offensichtlich: Unten ist /First gleich 14, weil die drei Header-Zeilen vierzehn Bytes belegen, und Objekt 7 sitzt 55 Bytes in den Body, weil Objekt 4 zu 54 Zeichen plus einem Trenner serialisiert wurde
// Decoded payload of: 12 0 obj << /Type /ObjStm /N 3 /First 14
// /Filter /FlateDecode /Length 118 >> stream
4 0
7 55
9 90
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
<< /Type /ExtGState /CA 1 /ca 1 >>
[ 0 0 595 842 ]
Zwei Mitgliedschaftsregeln sind absolut und stammen beide direkt aus §7.5.7. Ein Stream-Objekt kann niemals Mitglied sein, weil ein Stream rohe Bytes trägt, die in einen anderen Stream verschachtelt werden müssten. Und ein Mitglied muss ein vollständiger Objektwert sein, niemals eine bloße indirekte Referenz – ein komprimiertes Objekt, das nur 5 0 R ist, erzeugt eine Indirektion, die der Reader ohne bereits vorhandenes Wissen über das Ziel nicht auflösen kann. Die losLab PDF Library filtert beide Fälle bei der Kandidatensammlung heraus, zusammen mit dem Verschlüsselungs-Dictionary und Objekt 0, und packt dann, was übrig bleibt, in Gruppen von 200 pro Container. Diese Obergrenze ist eine Entscheidung zugunsten des Random-Access, keine Spezifikationsgrenze: Ein Reader, der nur ein Mitglied will, muss den gesamten Container entpacken, sodass übergroße Container kleine Lookups teuer machen
Warum müssen ObjStm-Mitglieder Typ-2-Cross-Reference-Einträge verwenden?
Weil ein gepacktes Objekt keinen Datei-Offset zum Vermerken hat. ISO 32000-1 §7.5.8 beantwortet das mit drei Eintragstypen in einem binären Cross-Reference-Stream: Typ 0 für freie Objekte, Typ 1 für gewöhnliche, an einem Byte-Offset gespeicherte In-Use-Objekte, und Typ 2 für komprimierte Objekte, deren zwei Datenfelder die Container-Objektnummer und den Mitgliedsindex darin halten. Ein gepacktes Objekt lässt sich in der klassischen Klartext-xref-Tabelle gar nicht ausdrücken, weshalb PDF 1.5 beide Features zusammen eingeführt hat
Die daraus folgende Reihenfolge bringt fast jede erste Implementierung ins Stolpern, auch unsere. Gewöhnliche Objekte erhalten Typ-1-Einträge. Die /ObjStm-Container selbst erhalten Typ-1-Einträge, denn ein Container ist ein völlig normales indirektes Stream-Objekt, das an einem echten Offset geschrieben wird. Nur die Mitglieder erhalten Typ-2-Einträge. Und der Cross-Reference-Stream selbst ist ein indirektes Objekt in der Datei, braucht also seinen eigenen Typ-1-Eintrag, der auf den Offset zeigt, an dem er gerade geschrieben wurde – denselben Offset, den startxref vermerkt. Eine frühe Version unseres Writers schloss Container-Objektnummern aus der Schreibschleife aus, statt Mitglieder auszuschließen, und das Ergebnis war eine Datei mit einem Cross-Reference-Stream und überhaupt keinen Object Streams: strukturell kohärent, semantisch leer, nachgelagert abgelehnt. Der /Size-Wert versteckt einen passenden Off-by-one-Fehler, da er die höchste Objektnummer plus eins ist und der Cross-Reference-Stream selbst als höchste Objektnummer vergeben wird, also mitgezählt werden muss
Das /W-Array dimensionieren: warum vier Bytes nicht reichen
Das /W-Array deklariert die Byte-Breite jedes der drei Felder, und die losLab PDF Library schreibt es als /W [1 Field2 Field3], wobei Feld 1 fest ein Byte für den Typcode hat und Feld 3 fest zwei Bytes, was sowohl Generationsnummern bis 65535 als auch Mitgliedsindizes abdeckt. Feld 2 ist dasjenige, das keine Konstante sein kann, weil es zwei unabhängige Größen trägt: In einem Typ-1-Eintrag ist es ein nur durch die Dateigröße begrenzter Byte-Offset, in einem Typ-2-Eintrag eine Container-Objektnummer und in einem Typ-0-Eintrag das nächste freie Objekt in der Kette. Ein fest vier Bytes breites Feld 2 funktioniert einwandfrei, bis die Datei 4 GB überschreitet – ab dann trunkiert jeder Offset jenseits der Grenze still, und die gesamte Tabelle wird zu Datenmüll. Der Writer durchsucht daher die zusammengestellte Tabelle nach dem größten Wert, den irgendein Feld-2-Slot je halten wird, einschließlich des Offsets des Cross-Reference-Streams selbst, und verbreitert das Feld bis auf acht Bytes
// Field 2 must hold the largest byte offset AND the largest
// ObjStm container number AND the largest free-chain target.
MaxField2Value := XRefStart;
for X := 0 to MaxObj do
begin
if XRefTable[X].InUse and (XRefTable[X].ObjStrNum > 0) then
Field2Value := XRefTable[X].ObjStrNum // type-2: container number
else
Field2Value := XRefTable[X].ObjPos; // type-1 offset / type-0 next-free
if Field2Value > MaxField2Value then
MaxField2Value := Field2Value;
end;
Field2 := 4;
while (Field2 < 8) and
(MaxField2Value > ((Int64(1) shl (Field2 * 8)) - 1)) do
Inc(Field2);
Field3 := 2; // generation numbers and member indices both fit
Sobald die Breiten feststehen, ist die Größe der Nutzlast exakt bekannt, sodass der Writer den gesamten Puffer vorab reserviert und ihn indexbasiert befüllt; Einträge byteweise an einen AnsiString anzuhängen macht den Tabellenaufbau quadratisch, was bei einer zehnseitigen Rechnung niemand bemerkt und bei einem Dokument mit zweihunderttausend Objekten jeder bemerkt. Zwei weitere Details halten strenge Reader zufrieden. /Index deklariert, welche Objektnummernbereiche die Tabelle abdeckt, und für ein vollständiges Neuschreiben ist das schlicht [0 N] ohne Lücken. Und jeder Slot, den der Writer tatsächlich nicht ausgegeben hat, muss standardmäßig frei statt in Gebrauch sein: Objekt 0 führt die Free-Kette an, jeder freie Slot verweist auf den nächsten, und ein Slot, der einmal ein gelöschtes Objekt hielt, behält seine um eins erhöhte Generationsnummer. Die begleitende Notiz zur Speichersicherheit beim Parsen nicht vertrauenswürdiger PDFs führt dasselbe Grenzen-Argument von der Leseseite aus
Warum darf der Cross-Reference-Stream niemals verschlüsselt sein?
Weil ein Reader ihn parsen muss, bevor er wissen kann, wie irgendetwas zu entschlüsseln ist. Der Cross-Reference-Stream verrät dem Reader, wo das /Encrypt-Dictionary liegt; wären seine Bytes selbst verschlüsselt, bräuchte der Reader den Dateischlüssel, um das Objekt zu finden, das den Dateischlüssel beschreibt. Die losLab PDF Library setzt das in einem einzigen Prädikat durch: ShouldCryptStreamData liefert False, sobald das Stream-Dictionary /Type /XRef trägt, sodass die Ausnahme unabhängig davon greift, welcher Pfad den Serializer erreicht
Der /ObjStm-Container erhält die gegenteilige Behandlung, und diese Asymmetrie ist beabsichtigt. Ein Container wird als Ganzes verschlüsselt, geschlüsselt auf seine eigene Objektnummer, genau wie jeder andere Stream. Seine Mitglieder werden nicht einzeln verschlüsselt – sie werden in ihrer entschlüsselten Klartextform gepackt, und der einzige Durchlauf über den zusammengestellten Container deckt sie ab, Strings eingeschlossen. Die Mitglieder doppelt zu verschlüsseln erzeugt eine Datei, die sich zu Chiffretext entschlüsselt, und weil die äußere Schicht erfolgreich ist, zeigt sich der Fehler tief im Objektgraphen als Parse-Fehler statt als Authentifizierungsfehler. Ein Objekt bleibt dann gänzlich außerhalb des Schemas: In einem verschlüsselten Dokument wird der Catalog als direktes Typ-1-Objekt gehalten und nie gepackt, weil das Packen den Loader zwänge, einen Object Stream zu entpacken und zu entschlüsseln, nur um die Dokument-Root zu erreichen, bevor der Entschlüsselungskontext, den die Root mit begründet, vollständig aufgebaut ist
Packing von Delphi aus einschalten
Der öffentliche Schalter ist PackObjectStreams, verfügbar als Feld auf TPDFlibSaveOptions, als eigenständiger Setter SetPackObjectStreams und als Property auf dem Dokumentobjekt. Er ist standardmäßig aktiviert und wird versionsabhängig automatisch gesperrt: Der Writer packt nur, wenn das Dokument bereits PDF 1.5 oder neuer ist, und ruft die interne Mindestversions-Prüfung auf, sodass ein gepacktes Dokument auf 1.5 angehoben statt falsch etikettiert wird. Nach dem Save meldet GetLastSaveUsedObjectStreams, ob die Sperre tatsächlich geöffnet hat – das ist die Prüfung, die man in einem Regressionstest haben will, statt eines Byte-Größenvergleichs
var
Doc: TPDFlib;
Options: TPDFlibSaveOptions;
begin
Doc := TPDFlib.Create;
try
if Doc.LoadFromFile('report.pdf', '') <= 0 then
Exit;
Doc.SetInformation(0, '1.5'); // packing is gated on PDF 1.5+
FillChar(Options, SizeOf(Options), 0);
Options.CompressContent := True;
Options.GarbageCollect := True; // drop orphans before packing
Options.PackObjectStreams := True;
if Doc.SaveToFileOptions('report-packed.pdf', Options) = 1 then
if Doc.GetLastSaveUsedObjectStreams = 1 then
Writeln('Saved with ObjStm containers and an xref stream');
finally
Doc.Free;
end;
end;
Die Reihenfolge zwischen Packing und Garbage Collection ist wichtig. Die Erreichbarkeitsanalyse muss zuerst laufen, denn ein Mitglied, das in einen Container überlebt, zieht den Container mit sich – ist ein lebendes Objekt gepackt, ist seine Container-Nummer per Definition erreichbar, und den Container hinwegzufegen strandet das Mitglied ohne Möglichkeit, es zu finden. Zuerst den Collector laufen zu lassen bedeutet auch, dass tote Objekte gar nicht erst in einen Container gelangen, woraus der kumulative Größengewinn stammt. Packing ergänzt die anderen Größenhebel, statt sie zu ersetzen; der Durchgang zur PDF-Dateigrößenoptimierung und Font-Subsetting behandelt die Hebel, die auf Stream-Nutzlasten wirken, während Object Streams auf die Struktur wirken
Grenzen, die man kennen sollte, bevor man es aktiviert
Incremental Saves packen nie. Ein Incremental Update hängt neue Objekte und einen neuen Cross-Reference-Abschnitt an, während frühere Revisionen physisch unangetastet bleiben; existierende Objekte in frische Container umzupacken würde also die Typ-1-Einträge verwaisen lassen, auf die die vorherige Revision weiterhin verweist. Die losLab PDF Library deaktiviert Packing, sobald der Append-Modus aktiv ist, und der Artikel zu Incremental Updates und Append-Mode-Streaming behandelt diesen Pfad vollständig. Dokumente unter PDF 1.5 behalten die Klartext-Cross-Reference-Tabelle bedingungslos: Ein 1.4-Consumer weiß nicht, was /ObjStm bedeutet, und ein Dokument stillschweigend hochzustufen, weil der Writer eine kleinere Datei bevorzugte, wäre der falsche Tausch, den man im Namen des Aufrufers eingehen würde. Ein optionaler Schlüssel, den wir bewusst nicht ausgeben, ist /Extends, den ISO 32000-1 §7.5.7 definiert, damit ein Container einen Vorgänger benennen und Reader eine Kette von Containern als logische Gruppe behandeln können. Er ist wirklich optional, jeder von uns geschriebene Container ist eigenständig und unabhängig dekodierbar, und ihn wegzulassen entfernt eine ganze Klasse von Zyklus- und Dangling-Reference-Fehlern aus dem Writer – Reader müssen /Extends natürlich trotzdem beachten, wenn sie ihm in Dateien anderer Erzeuger begegnen
Object-Stream-Packing und Cross-Reference-Stream-Ausgabe sind Teil der losLab PDF Library für Delphi und C++Builder, zusammen mit dem Garbage Collector und dem Content-Stream-Optimizer, mit denen sie zusammenspielen; die Produktseite führt die vollständige Referenz der Save-Optionen