Technischer Artikel

PDF-Seitenbäume: Seitenreihenfolge, Fan-Out und /Count

Objektnummer 1 ist nicht Seite 1. Diese eine Tatsache bringt mehr PDF-verarbeitenden Code zum Stolpern als jeder andere Teil des Formats, und der Grund zeigt sich erst, wenn man aufhört, darauf zu schauen, was ein Viewer rendert, und anfängt, den Objektgraphen zu betrachten, den der Viewer tatsächlich liest

Eine PDF-Datei ist eine Sammlung nummerierter indirekter Objekte. Jedes trägt eine Objektnummer und eine Generationsnummer, und andere Objekte zeigen mit einer Referenz der Form N G R darauf – 3 0 R meint die aktuelle Version von Objekt 3. Seiten sind Objekte wie jede andere, aber wo sie in der Datei sitzen und welche Nummern sie tragen, sagt nichts über die Anzeigereihenfolge. Die Reihenfolge kommt aus dem /Pages-Baum. Wer Objekte numerisch durchscannt, assembliert bei einem ordentlichen Anteil realer Dateien die Seiten in der falschen Reihenfolge

Im Baum steckt auch eine Menge weniger offensichtliches Verhalten. Seine Form entscheidet, wie schnell ein Viewer zu Seite 8.214 springen kann, seine inneren Knoten reichen Seitengröße und Schriften stillschweigend an die Blätter unter ihnen weiter, und die /Count-Summen, die die ganze Struktur schnell machen, werden von nichts durchgesetzt. Wir gehen durch alle drei, angefangen mit der Reihenfolge selbst

Der Seitenbaum legt die Reihenfolge fest

Jedes PDF hat einen Dokumentkatalog (ISO 32000-2 §7.7.2), und der /Pages-Eintrag des Katalogs zeigt auf den Wurzelknoten des Seitenbaums. Diese Wurzel ist ein Dictionary mit /Type /Pages, einem /Kids-Array aus indirekten Referenzen und einem /Count, der die Zahl der Blattseiten darunter angibt. Die Anzeigereihenfolge ist ein Tiefendurchlauf von links nach rechts durch diesen Baum. Das ist die ganze Regel

PDF-Diagramm eines PDF-Seitenbaums, dessen Kids-Array die Anzeigereihenfolge unabhängig von den Objektnummern festlegt
Der Katalog erreicht den Wurzel-/Pages-Knoten, und der Tiefendurchlauf durch /Kids legt jede Anzeigeposition fest, unabhängig von der Objektnummerierung

Eine minimale Drei-Seiten-Datei macht das konkret:

%PDF-1.7

1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj

2 0 obj
<< /Type /Pages /Kids [20 0 R  4 0 R  9 0 R] /Count 3 >>
endobj

% Objekt 4 steht dritt in der Datei, ist aber Seite 2 in der Anzeigereihenfolge
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 5 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% Objekt 9 steht viert, ist aber Seite 3
9 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 10 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

% Objekt 20 steht zuletzt, ist aber Seite 1; Kids[0] entscheidet, nicht die Objektnummer
20 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]
   /Contents 21 0 R /Resources << /Font << /F1 6 0 R >> >> >>
endobj

Das /Kids-Array liest sich [20 0 R 4 0 R 9 0 R], also ist Objekt 20 Seite 1, Objekt 4 ist Seite 2 und Objekt 9 ist Seite 3. Code, der Objekte in numerischer Reihenfolge durchläuft und alles mit /Type /Page einsammelt, bekommt diese Datei falsch. Schlimmer noch: Die Ausgabe sieht trotzdem plausibel aus – richtige Seitenzahl, erkennbarer Inhalt auf jeder Seite, falsche Reihenfolge

Generatoren erzeugen solche Layouts aus ganz gewöhnlichen Gründen. Eine Bibliothek, die vorab Objektnummern für alle Seiten reserviert, nummeriert sie in Erstellungsreihenfolge und schreibt die Bytes dann in der Reihenfolge, die ihrem Serializer passt. Ein Merge-Tool nummeriert die Objekte jedes Quelldokuments um, um Kollisionen zu vermeiden, also landen die Seitenobjekte verstreut in der kombinierten Objekttabelle, während das neue /Kids-Array der Wurzel die korrekte Reihenfolge hält. Und inkrementelle Updates hängen neue Objekte mit frischen Nummern ans Dateiende, sodass eine in einer Revision hinzugefügte Seite nahe am Ende des Byte-Stroms liegt, selbst wenn sie an Position 1 gehört

Genau der letzte Fall beißt zu. Ein Testkorpus aus einem einzigen Tool kann komplett bestehen, und dann kommen Dateien aus einem Merge-Workflow oder Dokumente, die ein paar Revisionen durchlaufen haben, durcheinander heraus. Heuristische Fixes halten nicht, denn das Versagen hängt vom Generator und von der Bearbeitungshistorie der Datei ab. Die echte Lösung ist kurz: Beim Katalog starten, /Pages auflösen, /Kids rekursiv durchlaufen und die Blätter in der Reihenfolge ausgeben, in der man ihnen begegnet. Unsere Fallstudie zum Debuggen der Seitenreihenfolge verfolgt genau dieses Symptom über eine Fehldiagnose bis zur Ursache, mit dem rekursiven Traversierungscode und qpdf-Schritten zur Kontrolle des Ergebnisses

Fan-Out ist eine Performance-Entscheidung

Die Spezifikation erlaubt zwei Formen. Schlichte Generatoren emittieren einen flachen Baum, einen einzigen Wurzel-/Pages-Knoten, dessen /Kids nichts außer /Page-Blättern hält. Nichts zwingt einen Writer zur Verschachtelung, und ein Dokument mit 10.000 Seiten und 10.000 Blattverweisen in einem einzigen Array ist konform. Die PDF-Referenz empfiehlt dennoch einen ausbalancierten Baum für große Dokumente, und gängige Generatoren folgen diesem Rat mit einem moderaten Fan-Out – typischerweise ein paar Dutzend Kinder pro Zwischenknoten, wobei jeder Zwischenknoten sein eigenes /Kids und seinen eigenen /Count hat

Der Grund liegt darin, was ein Viewer lesen muss, bevor er überhaupt etwas anzeigen kann. Nehmen wir einen direkten Sprung zu Seite 8.214 dieser 10.000-Seiten-Datei. Bei einem flachen Baum muss der Viewer zunächst den Wurzelknoten parsen, und dieser Wurzelknoten ist ein einziges riesiges Array: bei rund acht Byte pro indirekter Referenz ein 80-KB-Objekt, das vollständig tokenisiert werden muss, bevor Eintrag 8.213 aufgelöst werden kann. Bei einem ausbalancierten Baum mit Fan-Out 32 liest derselbe Sprung die Wurzel, vergleicht laufende /Count-Summen, um das richtige Kind auszuwählen, und steigt ab — insgesamt drei oder vier kleine Dictionaries, jedes nur wenige hundert Byte groß. Das ist der O(log n)-Direktzugriff, für den der Baum entworfen wurde, und es ist der ganze Grund, warum /Count an Zwischenknoten existiert: Er erlaubt einem Reader, einen kompletten Teilbaum zu überspringen, ohne auch nur ein einziges Objekt darin zu öffnen

Die Baumform bestimmt auch die Kosten des Bearbeitens. Ein inkrementelles Update, das eine Seite einfügt, muss jeden Knoten neu schreiben, dessen /Kids oder /Count sich geändert hat – also den Pfad vom Elternknoten des neuen Blatts bis zur Wurzel. In einem ausbalancierten Baum ist dieser Pfad eine Handvoll kleiner Dictionaries, die an die Datei angehängt werden. In einem flachen Baum ist der „Pfad“ das riesige Wurzel-Array, das bei jeder Revision vollständig dupliziert wird. Ein Vertrag, der dreißig Prüf- und Kommentierungszyklen durchläuft, kann am Ende dreißig überholte Kopien desselben 80-KB-Arrays in seinem Byte-Strom mitschleppen

PDF-Diagramm: ein ausgewogener PDF-Seitenbaum aus kleinen /Pages-Knoten gegenüber einem flachen Baum, dessen Wurzel ein riesiges /Kids-Array hält, mit Hervorhebung schnelleren Direktzugriffs und billigerer inkrementeller Updates
Ein balancierter Baum beantwortet einen Sprung zu Seite 8.214 in drei oder vier kleinen Dictionarys, während ein flacher Baum bei jedem Öffnen und jeder Revision ein einziges gewaltiges /Kids-Array parst und umschreibt

Für verarbeitenden Code hat Verschachtelung eine drastischere Folge. Man kann nicht annehmen, dass die erste Ebene von /Kids Seiten enthält, also muss jedes Kind geprüft werden: /Type /Pages heißt rekursieren, /Type /Page heißt Blatt. Code, der nach der ersten Ebene aufhört, funktioniert auf jeder flachen Datei, der er begegnet, und lässt dann beim ersten Generator, der sich für Verschachtelung entscheidet, stillschweigend ganze Teilbäume fallen

Innere Knoten tragen vererbte Attribute

Zwischenknoten dienen nicht nur der Weiterleitung. Vier Seitenattribute sind vererbbar (ISO 32000-2 §7.7.3.4): /Resources, /MediaBox, /CropBox und /Rotate. Jedes davon darf auf einem /Pages-Knoten sitzen, wo es für jedes Blatt darunter gilt, sofern kein Nachfahre es überschreibt. Ein Writer, der einen Bericht mit einem Anhang im Querformat erzeugt, kann dieses Layout direkt im Baum ausdrücken:

5 0 obj   % Dokumentwurzel
<< /Type /Pages /Count 6 /Kids [6 0 R  7 0 R] >>
endobj

6 0 obj   % Berichtskörper: Hochformat A4, Fließtextschrift
<< /Type /Pages /Parent 5 0 R /Count 3
   /Kids [30 0 R  31 0 R  32 0 R]
   /MediaBox [0 0 595 842]
   /Resources << /Font << /F1 8 0 R >> >> >>
endobj

7 0 obj   % Anhang: Querformat A4, gedreht, eigene Schrift
<< /Type /Pages /Parent 5 0 R /Count 3
   /Kids [40 0 R  41 0 R  42 0 R]
   /MediaBox [0 0 842 595] /Rotate 90
   /Resources << /Font << /F2 9 0 R >> >> >>
endobj

40 0 obj  % Anhangseite: erbt Größe, Drehung, Schriften
<< /Type /Page /Parent 7 0 R /Contents 43 0 R >>
endobj

Die Objekte 40 bis 42 sind fast leer. Seitengröße, Drehung und Schriftressourcen kommen alle von Knoten 7, was die Datei kompakt und selbstwartend hält: Fügt man eine vierte Seite unter dem Anhangsknoten hinzu, kommt sie im Querformat heraus, ohne dass irgendwer sie anfasst

Es bedeutet auch, dass die Eigenschaften einer Seite nicht alle auf der Seite liegen. Objekt 40 hat keinen /MediaBox-Schlüssel, und das ist völlig legal – ein Reader, der den fehlenden Schlüssel als fehlerhafte Eingabe behandelt, weist gültige Dateien zurück. Die Suche muss die /Parent-Kette hinaufgehen, bis sie den Eintrag findet oder die Wurzel erreicht. Dieser Weg braucht einen Zyklusschutz. Eine beschädigte Datei kann eine /Parent-Referenz tragen, die auf einen bereits besuchten Knoten zurückzeigt, und ohne ein Set besuchter Objekte dreht die Suche sich für immer

Derselbe Mechanismus erzeugt die klassische Gefahr beim Verschieben von Seiten. Angenommen, ein Tool verschiebt Objekt 40 in den Berichtskörper, indem es die beiden /Kids-Arrays bearbeitet und /Parent auf Knoten 6 umbiegt. Die Verschiebung ist strukturell gültig, doch Objekt 40 erbt nun die Hochformat-/MediaBox, keine Drehung und die Schrift /F1 — während sein Content-Stream weiterhin /F2 auswählt, das sich nicht mehr auflösen lässt. Die Seite schrumpft, verliert ihre Drehung und ihren Text in einer einzigen Bearbeitung. Robuster Umordnungscode materialisiert daher die aufgelösten Werte aller vier vererbbaren Attribute auf dem Seiten-Dictionary, bevor er es neu verankert. Wer schon einmal eine Seite in einem Editor gezogen und dabei ihre Größe oder Ausrichtung sich ändern gesehen hat, hat genau diesen Mechanismus beobachtet

PDF-Diagramm vererbbarer PDF-Seitenattribute (/Resources, /MediaBox, /CropBox, /Rotate), die von inneren /Pages-Knoten hinunter zu Blattseiten fließen, mit der Warnung, dass das Umhängen einer Seite stillschweigend alles tauscht, was sie erbt
Vererbbare Attribute leben auf inneren Knoten und fließen zu jedem Blatt unter ihnen, daher tauscht das Umhängen einer Seite stillschweigend ihre Größe, Drehung und Schriften

Abflachen: legal, üblich, gelegentlich teuer

Viele Tools gehen den umgekehrten Weg. Minimale Writer erzeugen einen einstufigen Baum, weil das einfach ist, und viele Zusammenführungs- und Aufteilungswerkzeuge bauen jeden gelesenen Baum in ein einziges flaches /Kids-Array um, weil das Erzeugen einer ausbalancierten Struktur zusätzliche Arbeit ist und flache Ausgabe immer konform ist. Ein korrekter Neuaufbau muss dabei gleichzeitig die Vererbung auflösen: Jedes Attribut, das ein Blatt geerbt hat, muss auf das Blatt kopiert oder — falls im ganzen Dokument einheitlich — auf die neue Wurzel gehoben werden, sonst verändert die Ausgabe die Geometrie genau wie im Fall des Seitenverschiebens

Bei typischen Dokumenten ist das Abflachen harmlos. Es schadet erst in großem Maßstab, auf die beiden bereits beschriebenen Arten: Das Wurzel-Array wird zu einem großen Objekt, das jedes Öffnen und jeder Seitensprung vollständig parsen muss, und jede strukturelle Bearbeitung schreibt es komplett neu. Was das Abflachen nicht zerstört, ist die Deduplizierung über indirekte Referenzen — ein flacher Baum, in dem alle 10.000 Seiten auf dasselbe /Resources-Dictionary-Objekt zeigen, bleibt weiterhin dedupliziert. Verloren geht nur die Möglichkeit, den Eintrag auf der Seite wegzulassen und ihn von einem Vorfahren liefern zu lassen

Wenn /Count lügt

/Count ist reine Buchführung: Er muss der Anzahl der Blattseiten im Teilbaum des Knotens entsprechen, und nichts im Dateiformat erzwingt das. Zwei Beschädigungsmuster erklären die meisten der in freier Wildbahn beobachteten lügenden Zähler

Das erste ist der veraltete Zähler, der von einem inkrementellen Update zurückgelassen wird. Ein Editor fügt eine Seite ein, schreibt den unmittelbaren Elternknoten mit neuem /Kids und aktualisiertem /Count neu, hängt beides an die Datei an — und rührt die Vorfahren nie an:

% Ursprüngliche Revision
12 0 obj
<< /Type /Pages /Count 9 /Kids [13 0 R  14 0 R  15 0 R] >>
endobj

14 0 obj
<< /Type /Pages /Parent 12 0 R /Count 3
   /Kids [50 0 R  51 0 R  52 0 R] >>
endobj

% Angehängte Revision: eine Seite wurde in den mittleren Zweig eingefügt.
% Objekt 14 wird ersetzt; Objekt 12 wird nie neu geschrieben
14 0 obj
<< /Type /Pages /Parent 12 0 R /Count 4
   /Kids [50 0 R  51 0 R  90 0 R  52 0 R] >>
endobj

Der Baum enthält jetzt zehn Blätter, aber die Wurzel sagt weiterhin neun. Ein Viewer, der der Wurzel vertraut, meldet neun Seiten in seiner Seitenanzeige. Einer, der innere Zähler für eine binäre Suche beim Seitensprung nutzt, berechnet für jede Seite nach dem Einfügepunkt einen falschen Index. Ein vollständiger Durchlauf findet zehn. Drei verschiedene Antworten, eine Datei

PDF-Diagramm eines veralteten /Count in einem PDF-Seitenbaum: eine eingefügte Seite, eine Wurzel, die weiterhin neun meldet, und drei Konsumenten mit unterschiedlichen Summen, bis eine vollständige Traversierung zehn findet
Eine eingefügte Seite lässt den gespeicherten /Count der Wurzel bei neun stehen, während der Durchlauf zehn findet, und strenge Parser verwerfen, was nachsichtige Viewer stillschweigend ignorieren

Es gibt einen leiseren Weg, an derselben Datei zu scheitern. Code, der den Wurzelzähler liest, den Baum durchläuft und aufhört, sobald er so viele Seiten gesammelt hat, wie der Zähler verspricht, sieht Seite zehn nie. Keine Exception, keine Warnung, nur ein Dokument, das eine Seite zu kurz ist. /Count als Schleifengrenze oder Bounds-Check zu verwenden ist der Fehler; der Durchlauf muss laufen, bis der Baum erschöpft ist, was auch immer die gespeicherte Zahl sagt

Das zweite Muster ist der Zähler, der niemals richtig sein konnte: negativ, null bei einem gefüllten Knoten oder absurd riesig. Diese stammen aus Fuzzing, aus Übertragungsschäden und gelegentlich aus Rechenfehlern in Editoren. Sie sind speziell für Code gefährlich, der /Count für die Allokation vertraut — ein Array anhand eines /Count von -3 zu dimensionieren, löst bestenfalls einen Bereichsfehler aus, und dasselbe mit einem /Count von zwei Milliarden zu tun, ist eine Denial-of-Service-Allokation. Der Wert ist nicht vertrauenswürdige Eingabe, wie jede andere Zahl in der Datei

Parser teilen sich in zwei Lager. Strikte Konsumenten – Preflight-Tools, PDF/A-Validatoren, Archivierungspipelines – vergleichen /Count mit dem Ergebnis des Durchlaufs und weisen die Datei zurück oder markieren sie. Interaktive Viewer sind fast durchweg tolerant: Sie durchlaufen den Baum, ermitteln den tatsächlichen Zähler und ignorieren den gespeicherten Wert stillschweigend – genau deshalb kann eine Datei mit veraltetem Zähler jahrelang unbeanstandet kursieren, bis sie in einem automatisierten Workflow auf einen strikteren Parser trifft. Für Bibliothekscode halten wir den Mittelweg für den richtigen Standard: /Count als Hinweis für die Vorabreservierung verwenden, und für das Überspringen von Teilbäumen, sobald er verifiziert ist, während der Durchlauf die maßgebliche Quelle bleibt

In Produktion kündigen sich diese Fehler selten als Baumprobleme an. Sie zeigen sich als durcheinandergebrachte Seiten, einer fehlenden Seite am Ende eines Merge oder einer Seite, die nach einer Umordnung ihre Größe oder Ausrichtung wechselt. Die Fallstudie zum Debuggen verfolgt einen solchen Bericht durch den Baum zurück. Für die umliegende Dateianatomie (Objekte, Cross-Reference-Daten, Trailer) ist der Überblick über die PDF-Dateistruktur der richtige Einstieg

Die HotPDF Delphi Component übernimmt all das intern: Sie durchläuft verschachtelte Bäume beliebiger Tiefe, löst vererbte Attribute auf, wenn Seiten kopiert oder verschoben werden, und verifiziert /Count gegen die tatsächliche Blattzahl, statt ihm zu vertrauen, sodass Seitenindizes in ihrer API immer logische Seiten bedeuten