Technischer Artikel

Linearisierte PDF-Ausgabe in Delphi: HotPDF-Hint-Tabellen

HotPDF schreibt linearisierte PDF-Dateien, das Layout, das Acrobat als Fast Web View bezeichnet, über die Eigenschaft LinearizeOutput von THotPDF. Wird sie vor BeginDoc gesetzt, ordnet HotPDF den fertigen Objektgraphen so um, dass ein byte-range-fähiger Reader Seite eins anzeigen kann, sobald nur der Anfang der Datei geladen wurde, statt zuerst das gesamte Dokument herunterzuladen. Der Mechanismus ist in ISO 32000-1 Anhang F beschrieben

Der Grund, warum das relevant ist, klingt unspektakulär. Eine gewöhnliche PDF-Datei legt ihre Querverweistabelle ans Ende, sodass ein Viewer das letzte Byte erreichen muss, bevor er weiß, wo sich irgendetwas befindet. Übergibt man einem Browser einen 200-seitigen gescannten Bericht, starrt der Nutzer während der gesamten Übertragung auf einen Ladeindikator, obwohl er nur Seite 1 sehen wollte. Linearisierung behebt das, indem die Kosten beim Schreiben anfallen. Dieser Artikel behandelt genau diesen Schreibpfad: die Partitionierung, die Messschleife und die harten Grenzen; für den konzeptionellen Hintergrund, was Fast Web View bringt, deckt die frühere Erklärung zu PDF-Linearisierung und Fast Web View das Thema ab

Was das linearisierte Layout tatsächlich garantiert

Eine linearisierte Datei ist eine gewöhnliche PDF-Datei mit einer extrem spezifischen physischen Anordnung, und jede Garantie, die sie bietet, ergibt sich aus dieser Anordnung, nicht aus einem neuen Objekttyp. HotPDF schreibt die Teile in der von Anhang F vorgeschriebenen Reihenfolge: das Linearisierungsparameter-Dictionary innerhalb der ersten 1024 Bytes, eine frühe Querverweistabelle, die Objekte auf Dokumentebene, den primären Hint-Stream, die erste Seite mit ihren privaten Objekten, dann die restlichen Seiten, dann die gemeinsam genutzten Objekte, dann alles Übrige und schließlich die Haupt-Querverweistabelle

Die Partitionierung wird abgeleitet, nicht deklariert. HotPDF durchläuft den Referenzgraphen ausgehend von jedem Seitenobjekt und protokolliert für jedes indirekte Objekt, wie viele Seiten es erreichen und welche Seite es zuerst erreicht hat. Ein Objekt, das von genau einer Seite genutzt wird, wird dieser Seite privat zugeordnet. Ein von mehr als einer Seite erreichtes Objekt wird als gemeinsam genutzt eingestuft. Der Catalog, alles, worauf er unter /ViewerPreferences, /OpenAction, /Threads und /AcroForm verweist, sowie das Verschlüsselungs-Dictionary bei aktivem Schutz bilden die Gruppe auf Dokumentebene, die allem vorausgehen muss. Seitenbaum-Knoten werden bewusst zurückgehalten, damit sie den Abschnitt der ersten Seite nicht verunreinigen

Das Parameter-Dictionary trägt die Zahlen, die ein Reader benötigt, bevor er sonst irgendetwas gelesen hat: /L für die Gesamtdateilänge, /H für Offset und Länge des Hint-Streams, /O für die Objektnummer der ersten Seite, /E für das Byte, an dem der Abschnitt der ersten Seite endet, /N für die Seitenzahl und /T für den Offset des Eintrags der Haupt-Querverweistabelle. Jeder dieser Werte ist ein Byte-Offset in eine Datei, die zum Zeitpunkt, an dem man sie schreiben muss, noch gar nicht existiert

Warum müssen die Hint-Tabellen-Offsets konvergieren?

Weil die Zahlen im Parameter-Dictionary die Datei beschreiben, die sie selbst enthält, und jede Änderung an ihnen die Datei verändert. Das ist die zentrale Schwierigkeit eines linearisierenden Writers, und deshalb misst HotPDF wiederholt, statt nur einmal zu schreiben. Wird /T von 6 auf 7 Stellen erweitert, wächst das Parameter-Dictionary um ein Byte; der Header wächst; jedes Objekt verschiebt sich; die Haupt-Querverweistabelle wandert; /T braucht nun einen anderen Wert. Das Layout muss einen Fixpunkt erreichen, bevor auch nur ein Byte der eigentlichen Ausgabe geschrieben wird

HotPDF löst das mit einer begrenzten Iteration. Zunächst wird jedes Objekt in einen zählenden Stream serialisiert, der die Länge festhält, ohne die Bytes zu behalten, sodass jedes Objekt eine bekannte serialisierte Größe erhält. Anschließend läuft ein Layout-Durchgang, der der Gruppe auf Dokumentebene, dem Hint-Stream, der Gruppe der ersten Seite, den späteren Seitengruppen, der gemeinsam genutzten Gruppe und dem Rest Offsets zuweist und meldet, wo die Haupt-Querverweistabelle landen würde. Dieses Ergebnis fließt als Eingabe in den nächsten Durchgang zurück. Die Schleife ist auf acht Versuche begrenzt, und ausbleibende Konvergenz löst eine Exception aus, statt eine Datei mit plausibel wirkenden, aber falschen Offsets zu erzeugen

CandidateMainOffset := 0;
for Attempt := 0 to 7 do
begin
  CalculateLayout(CandidateMainOffset, FirstXRefData,
    HintOffset, EndFirstPage, NewMainOffset);
  if NewMainOffset = CandidateMainOffset then
    Break;
  CandidateMainOffset := NewMainOffset;
end;
if NewMainOffset <> CandidateMainOffset then
  raise Exception.Create('Linearization layout did not converge');

Zwei Details verhindern, dass die Schleife ins Schwanken gerät. Das Parameter-Dictionary wird in einen festen 384-Byte-Slot geschrieben, mit Leerzeichen aufgefüllt, sodass sein eigenes Wachstum das Layout nie destabilisieren kann; überschritte der Dictionary-Text diese Reservierung jemals, löst HotPDF eine Exception aus, statt stillschweigend alles zu verschieben. Und nach der Konvergenz führt HotPDF einen weiteren bestätigenden Layout-Durchgang aus und prüft die Länge des Hint-Streams erneut, weil der Hint-Stream selbst Offsets kodiert, die erst feststanden, nachdem sich das Layout stabilisiert hatte. Der Lohn dieses ganzen Messaufwands ist, dass HotPDF niemals eine zweite Kopie des Dokuments puffert: Sobald die Offsets feststehen, werden Objekte direkt in den Zielstream serialisiert, mit einer Prüfung an jeder Abschnittsgrenze, dass die geschriebenen Bytes dem versprochenen Offset entsprechen

Aktivierung von Delphi aus

Die API-Oberfläche besteht aus einem einzigen Boolean, und die einzige Anforderung ist, ihn vor Beginn der Generierung zu setzen. LinearizeOutput ist standardmäßig False, und der Layout-Durchgang läuft beim Schreiben des Dokuments, sodass eine Zuweisung nach EndDoc nichts bewirkt

var
  PDF: THotPDF;
begin
  PDF := THotPDF.Create(nil);
  try
    PDF.FileName := 'fast-view.pdf';
    PDF.Version := pdf17;
    PDF.LinearizeOutput := True;      // must precede BeginDoc
    PDF.BeginDoc;
    PDF.Canvas.TextOut(72, 72, 'First page');
    PDF.EndDoc;
  finally
    PDF.Free;
  end;
end;

Ein Bereitstellungsvorbehalt übertrifft alles auf der Code-Seite. Linearisierung zahlt sich nur aus, wenn der Transport HTTP-Range-Requests unterstützt. Liefert man dieselbe Datei über einen Endpunkt aus, der sie komplett streamt, oder über eine CDN-Konfiguration, die Range ignoriert, hat man sich lediglich einen langsameren Schreibpfad und eine größere Datei ohne sichtbaren Nutzen eingehandelt. Erst den Server prüfen, dann den Code

Warum überschreibt Linearisierung UseXRefStream und UseObjectStreams?

Weil der linearisierende Writer für jedes Objekt einen eigenen, direkt adressierbaren Byte-Offset benötigt, und beide Funktionen nehmen ihm genau das. HotPDF schreibt deshalb klassische Text-Querverweistabellen und ungepackte indirekte Objekte, sobald LinearizeOutput aktiviert ist, auch wenn der Aufrufer zusätzlich UseXRefStream oder UseObjectStreams gesetzt hat. Das ist eine bewusste Überschreibung, kein Konflikt, den man selbst auflösen müsste

Die Begründung folgt aus den Hint-Tabellen. Eine Hint-Tabelle beschreibt, wo ein Seitenabschnitt beginnt und wie lang er ist, damit ein Reader genau diesen Bereich anfordern kann. Ein in einen /ObjStm-Container gepacktes Objekt hat überhaupt keinen eigenständigen Offset; es existiert nur als Ausschnitt innerhalb eines anderen komprimierten Streams, der als Einheit abgerufen und entpackt werden muss. Wer bei der Dateigröße auf Object Streams gesetzt hat, sollte wissen, dass Linearisierung und Kompression hier in entgegengesetzte Richtungen ziehen; die Abwägung findet sich im begleitenden Beitrag zu Object Streams und inkrementellen Updates in HotPDF. Dieselbe Spannung prägt Hybrid-Querverweis-Dateien, die genau deshalb existieren, damit ältere Reader neben stream-basierten Tabellen weiterhin funktionieren, wie im Artikel zu hybriden Querverweis-Streams in aus Office erzeugten PDFs beschrieben

Es gibt außerdem eine Versionsuntergrenze. Linearisierung erfordert PDF 1.2 oder neuer. Ist die gewählte Version älter, hebt HotPDF sie automatisch an, es sei denn, StrictVersionLock ist gesetzt; dann löst das Schreiben eine Exception aus, statt ein absichtlich fixiertes Dokument stillschweigend hochzustufen

Die 4-GiB-Wand, und warum HotPDF verweigert statt abzuschneiden

Linearisierungs-Hint-Tabellen speichern Offsets als 32-Bit-Werte, sodass eine linearisierte Datei nichts bei oder oberhalb von 4 GiB adressieren kann, und HotPDF weist eine solche Ausgabe mit einer expliziten Exception zurück, statt eine Datei mit übergelaufenen Offsets zu schreiben. Die Grenze ist keine Implementierungsentscheidung von HotPDF; sie ist die Breite der Felder, die Anhang F definiert

Die Prüfung greift an drei Stellen, und alle drei sind relevant. HotPDF validiert jedes Objekt, sobald seine serialisierte Länge bekannt ist, validiert jede Seitenabschnittslänge beim Aufbau der Hint-Einträge und validiert die endgültige Dateilänge, nachdem die Größe der Haupt-Querverweistabelle feststeht. Frühes Fehlschlagen ist der ganze Sinn der Sache: Eine Hint-Tabelle mit einem stillschweigend abgeschnittenen Offset erzeugt eine Datei, die in einem Viewer, der sie komplett herunterlädt, korrekt öffnet und nur bei dem Byte-Range-Client versagt, für den Linearisierung eigentlich gedacht war – der denkbar schlechteste Fehlerfall, weil er sich im eigenen Test-Viewer nie zeigt. Wer mehrere Gigabyte Ausgabe erzeugt, für den ist Linearisierung nicht das richtige Werkzeug; die passende Richtung weist der Streaming-Ansatz in den Notizen zur Direct File API für große PDF-Workflows

Linearisierung an einer geladenen Datei erkennen

THotPDF.IsLoadedLinearized meldet, ob das aktuell geladene Dokument bereits in linearisierter Form geschrieben wurde, und die Antwort stammt aus einer Momentaufnahme vor dem Parsen, nicht aus dem laufenden Stream. HotPDF liest die ersten 1024 Bytes ab Position null des Quellstreams, sucht darin nach dem ersten Schlüsselwort obj und danach nach einem /Linearized-Eintrag mit dem Wert 1 und speichert das boolesche Ergebnis zwischen

var
  PDF: THotPDF;
  PageCount: Integer;
begin
  PDF := THotPDF.Create(nil);
  try
    PageCount := PDF.LoadFromFile('incoming.pdf');
    if (PageCount > 0) and (not PDF.IsLoadedLinearized) then
      Writeln('Source is not Fast Web View ready');
  finally
    PDF.Free;
  end;
end;

Zwei Einschränkungen in dieser Beschreibung sind tragend. Die Erkennung kann sich nicht auf die Stream-Position verlassen, weil der Parser sie zum Zeitpunkt der Anwendungsabfrage bereits weiterbewegt hat, und sie kann nicht bei Bedarf erneut lesen, weil LoadFromFile den internen Quellstream nach Abschluss des Ladens freigibt. Daher das Design mit Erfassung vor dem Parsen und anschließendem Zwischenspeichern. Der Scan ist zudem bewusst wörtlich, was den Wert angeht: Nur /Linearized 1 oder eine numerisch gleichwertige Form mit rein nullstelligem Bruchteil wird akzeptiert, weil eine Datei, deren Parameter-Dictionary etwas anderes aussagt, das Versprechen aus Anhang F nicht einlöst

Eine Delphi-Record-Falle, die man sich merken sollte

Lokale Records mit dynamischen Arrays initialisieren nur ihre verwalteten Felder und sonst nichts, und wer neben dem Array ein einfaches Count-Feld führt, muss es selbst zurücksetzen. Das hat die Linearisierungs-Partitionierung während der Entwicklung getroffen, und es ist genau die Art von Fehler, die einen Tag kostet, weil eine Plattform ihn verbirgt

type
  THPDFLinearIndexList = record
    Values: THPDFIntegerArray;  // managed field: cleared for you
    Count: Integer;             // plain field: whatever was on the stack
  end;

// Required, not cosmetic:
Part4 := Default(THPDFLinearIndexList);
Part6 := Default(THPDFLinearIndexList);
Part8 := Default(THPDFLinearIndexList);
Part9 := Default(THPDFLinearIndexList);

Das dynamische Array-Feld ist referenzgezählt, sodass der Compiler es auf null setzt. Das daneben liegende Count ist ein gewöhnlicher Integer ohne diese Garantie, und ein uninitialisiertes Count lässt den allerersten Append an einem beliebigen Index landen. Unter Win32 enthielt der Stack-Slot zufällig null, der Append landete bei Index 0, und jeder Test bestand. Unter Win64 schrieb derselbe Code über das Ende des Arrays hinaus. Die Lehre reicht weit über die Linearisierung hinaus: Mischt ein Record verwaltete und unverwaltete Felder, weist man Default(TRecord) zu und hört auf zu rätseln, welche Felder der Compiler abdeckt – und ein grüner Win32-Lauf ist niemals ein Beleg für korrekte Initialisierung

Die hier beschriebenen Member LinearizeOutput und IsLoadedLinearized sind Teil der Standardausstattung der HotPDF-Komponente für Delphi und C++Builder; die Produktseite enthält die vollständige Property-Referenz einschließlich der Interaktionsregeln mit Querverweis-Streams, Object Streams und Versionssperre