Technischer Artikel

Wie ein PDF funktioniert, wenn ein Reader es öffnet

Öffnen Sie dasselbe Rechnungs-PDF auf Windows und auf einem Telefon, und die Zeilenumbrüche, Fonts und das Logo landen an identischen Stellen. Eine PDF-Datei ist eine Menge nummerierter Objekte plus ein Byte-Offset-Index, und ein Reader wie die PDFium-Engine in PDFium Component öffnet sie vom Ende: startxref, Xref-Tabelle, Katalog, Seitenbaum, Content Streams, dann Pixel

Was also ist eine PDF-Datei, und warum sieht sie überall gleich aus? Word- und RTF-Dokumente speichern Text und Stile und überlassen das Layout der Maschine, die sie öffnet, also fließt ein fehlender Font oder ein anderer Druckertreiber die Seiten neu um. PDF speichert stattdessen das fertige Ergebnis. Jeder Glyph und jede Zeile hat eine absolute Position in einem Koordinatenraum, gemessen in Punkt, die Font-Programme reisen in der Datei mit, und Bilder tragen ihre eigenen Farbdaten und Kompressionsfilter. Nichts wird zur Betrachtungszeit layoutet, weshalb jeder konforme Renderer auf jedem Betriebssystem dieselbe Seite malt. Das Format begann als PDF 1.0 mit Acrobat 1993, gebaut auf dem PostScript-Imaging-Modell, und jede Revision ergänzte einen Teil der Maschinerie, die unten beschrieben wird:

  • PDF 1.1 bis 1.4 brachten Verschlüsselung, interaktive Formulare, digitale Signaturen mit logischer Struktur und Transparenz
  • PDF 1.5 ergänzte Object Streams und Cross-Reference-Streams; PDF 1.6 ergänzte AES-Verschlüsselung
  • PDF 1.7 wurde ISO 32000-1:2008, und PDF 2.0 wurde ISO 32000-2:2017, überarbeitet 2020
  • PDF/A (ISO 19005), PDF/X (ISO 15930) und PDF/UA (ISO 14289) beschränken diese Basis für Archivierung, Druckproduktion und Barrierefreiheit

Was passiert, wenn ein Reader eine PDF-Datei öffnet?

Ein PDF zu öffnen ist eine Kette von Nachschlägen, die an den letzten Bytes der Datei beginnt und mit einer Bitmap endet. Fast jedes Glied in dieser Kette ist ein Dictionary, einer der Basisobjekttypen in ISO 32000-1 §7.3, und Dictionaries zeigen mit indirekten Referenzen wie 7 0 R (Objekt 7, Generation 0) aufeinander. Die Datei verhält sich also wie ein Graph von Objekten statt wie eine Folge von Seiten. Header, Body, Cross-Reference-Tabelle und Trailer sind in der Übersicht der PDF-Dateistruktur dargelegt, und ein minimales PDF, Zeile für Zeile kommentiert zeigt, wie obj, endobj und stream in einem Texteditor aussehen. Hier ist die Reihenfolge, der jeder Reader folgt

  1. Ans Ende der Datei springen und den Byte-Offset lesen, der hinter dem startxref-Schlüsselwort geschrieben steht
  2. Die Cross-Reference-Daten an jenem Offset laden, die jede Objektnummer auf eine Position in der Datei abbilden
  3. Den Trailer lesen und /Root zum Dokumentkatalog folgen
  4. Dem /Pages-Eintrag des Katalogs den Seitenbaum hinunter zum angefragten Seitenobjekt folgen
  5. Den Content Stream der Seite dekodieren und seine Operatoren der Reihe nach interpretieren
  6. Die Fonts, Bilder und Form XObjects, die jene Operatoren nennen, über /Resources auflösen
  7. Pfade, Glyphen-Umrisse und Bilder bei der Geräteauflösung in Pixel rastern

Schritt eins: startxref und die Cross-Reference-Tabelle

Ein Reader scannt ein PDF nie von oben. Eine wohlgeformte Datei endet mit dem Schlüsselwort startxref, einer Dezimalzahl wie 48211 und %%EOF, und jene Zahl ist der Byte-Offset der Cross-Reference-Daten. Eine naive Rückwärtssuche nach xref hält in startxref an, suchen Sie also nach dem vollen Schlüsselwort. Die klassische Tabelle benutzt Einträge fester Breite (§7.5.4), was einem Reader erlaubt, zum Eintrag jeder Objektnummer zu springen, ohne die anderen zu parsen; das Byte-Layout jedes Eintrags behandelt die Strukturübersicht. Der Trailer hinter der Tabelle nennt /Size, /Root und optional /Encrypt, /ID und /Prev

Wie ein PDF funktioniert: vertikale Anatomie einer PDF-Datei vom Header und Objektkörper über die Cross-Reference-Tabelle bis zum Trailer
Die Cross-Reference-Tabelle macht aus Objektnummern Byte-Offsets, also sucht ein Reader direkt zu jeder Seite, ohne den Rest zu durchkämmen

Jeder Eintrag ist eine absolute Byte-Position, also lässt alles, was Bytes verschiebt, etwa ein Editor, der Zeilenenden umschreibt, den Index auf falsche Stellen zeigen. Reader mit Reparaturdurchlauf bauen ihn neu, indem sie nach obj-Schlüsselwörtern scannen; Reader ohne einen weisen die Datei zurück, weshalb ein beschädigtes PDF im einen Viewer aufgeht und im anderen scheitert. Was am startxref-Offset sitzt, sagt Ihnen, welcher Fall vorliegt: das Schlüsselwort xref für eine klassische Tabelle, ein N 0 obj-Header für einen PDF-1.5-Cross-Reference-Stream, und alles andere für einen veralteten Offset. Der Helfer unten ist Ihr eigener Code, gebaut auf schlichten RTL-Streams

uses
  System.SysUtils, System.Classes;

// Eigener Helfer: Offset ist die Zahl, die hinter startxref geschrieben steht.
// Ordnet ein, was der Reader an jener Position finden wird
function DescribeXrefAt(const FileName: string; Offset: Int64): string;
var
  FS: TFileStream;
  Buf: TBytes;
  Head: string;
begin
  FS := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if Offset >= FS.Size then
      Exit('offset beyond end of file: the file needs a repair pass');
    FS.Position := Offset;
    SetLength(Buf, 32);
    SetLength(Buf, FS.Read(Buf[0], Length(Buf)));
  finally
    FS.Free;
  end;
  Head := TEncoding.ASCII.GetString(Buf);
  if Head.StartsWith('xref') then
    Result := 'classic cross-reference table'
  else if Head.Contains(' obj') then
    Result := 'cross-reference stream (PDF 1.5 or later)'
  else
    Result := 'stale offset: the file needs a repair pass';
end;

Ein Cross-Reference-Stream (§7.5.8) ist ein komprimiertes Objekt mit /Type /XRef, einem /W-Array mit der Byte-Breite jeder Spalte und binären Zeilen, die entweder auf einen Byte-Offset oder in einen Object Stream zeigen (§7.5.7). Die Trailer-Schlüssel wandern in sein Dictionary, die Datei hat also gar kein trailer-Schlüsselwort. HotPDF schreibt diese Form, wenn UseXRefStream auf THotPDF gesetzt ist. Für Dateien, die Ihre Kontrolle verlassen, etwa Mail-Anhänge oder Uploads an Druckereien, bleibt die klassische Tabelle die sicherere Wahl, denn Reader aus der Zeit vor PDF 1.5 parsen sie noch. PDF/A-1 entscheidet die Frage für Archive: ISO 19005-1 baut auf PDF 1.4 auf, und sein §6.1.4 stellt fest, dass Cross-Reference-Streams nicht benutzt werden dürfen, also schreibt HotPDF eine klassische Tabelle, wann immer ein PDF/A-1-Profil aktiv ist

48 0 obj
<< /Type /XRef /Size 49 /Root 1 0 R
   /W [1 3 1] /Index [0 49]
   /Filter /FlateDecode /Length 162 >>
stream
...binary rows: 1 + 3 + 1 bytes each...
endstream
endobj
startxref
48211
%%EOF

Von /Root zur richtigen Seite: Katalog und Seitenbaum

Der /Root des Trailers zeigt auf den Dokumentkatalog, und der /Pages des Katalogs auf die Wurzel des Seitenbaums. Jeder Zwischen-/Pages-Knoten trägt ein /Count der Blattseiten unter sich, also subtrahiert ein Reader, der Seite 5000 sucht, Zähler beim Abstieg und überspringt ganze Teilbäume ungeöffnet. ISO 32000-1 §7.7.3 verlangt keinen balancierten Baum; ein einzelnes /Kids-Array mit 10000 Einträgen ist legal, nur langsamer zu durchsuchen. Code, der nur das Blatt liest, fällt in eine Falle. /Resources, /MediaBox, /CropBox und /Rotate sind vererbbar (§7.7.3.4), also ist eine Seite ohne eigenes /MediaBox weiterhin gültig, weil der Wert bei einem Vorfahren lebt

12 0 obj
<< /Type /Page
   /Parent 3 0 R
   /MediaBox [0 0 612 792]
   /Resources << /Font << /F1 7 0 R >> >>
   /Contents 13 0 R
>>
endobj

Die Media Box setzt den Koordinatenraum für alles, was auf der Seite gezeichnet wird: Ursprung unten links, Y läuft nach oben, eine Einheit entspricht 1/72 Zoll, sofern kein /UserUnit aus PDF 1.6 sie skaliert. PDFium Component vollzieht den ganzen Abstieg, wenn Sie eine Seite per Nummer anfragen. TPdf.PageMetrics liefert die Seitengröße in Punkt mit bereits aufgelösten geerbten Boxen, dazu die Seitenrotation, das Listing unten meldet also korrekte Größen auch für Dateien, deren Blattseiten keine eigene Box tragen

uses
  System.SysUtils, PDFium;

// Druckt die Seitenzahl und die Größe jeder Seite in Punkt
procedure ListPages(const FileName: string);
const
  Degrees: array[TRotation] of Integer = (0, 90, 180, 270);
var
  Pdf: TPdf;
  I: Integer;
  M: TPdfPageMetrics;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;           // parst die Xref-Daten, den Trailer und den Katalog
    if not Pdf.Active then        // ein fehlgeschlagener Ladevorgang lässt Active = False
      raise Exception.Create('Cannot open ' + FileName);
    Writeln(Pdf.PageCount, ' pages');
    for I := 1 to Pdf.PageCount do
    begin
      M := Pdf.PageMetrics[I];    // 1-basiert; lädt die Seite durch den Baum
      Writeln(Format('Page %d: %.1f x %.1f pt, /Rotate %d',
        [I, M.Width, M.Height, Degrees[M.Rotation]]));
    end;
  finally
    Pdf.Free;
  end;
end;

Wie zeichnet ein PDF eine Seite auf dem Bildschirm?

Der sichtbare Inhalt einer Seite ist ein kleines Programm: Postfix-Operatoren in einem oder mehreren Content Streams, der Reihe nach interpretiert, nachdem der Reader den in /Filter genannten Dekodierer laufen ließ, meist /FlateDecode. BT öffnet ein Textobjekt, Tf wählt eine Font-Ressource und Größe, Td verschiebt die Textposition, Tj malt einen String, und ET schließt das Objekt. Pfade werden mit m, l, c und re gebaut, dann mit f gefüllt oder mit S gestrichen. Der Graphics State (§8.4) hält, was jene Operatoren implizit lesen, etwa die Transformationsmatrix, Farben und den Clipping-Pfad. q schiebt eine Kopie, Q poppt sie, eine im Paar gesetzte Transformation kann also nicht in späteren Content lecken

q
1 0 0 1 72 700 cm     % Ursprung nach (72, 700) verschieben
BT
  /F1 12 Tf           % Font-Ressource F1 mit 12 Punkt
  0 0 Td
  (Invoice 2026-0417) Tj
ET
0.2 0.2 0.8 RG        % Stroke-Farbe, durch q/Q begrenzt
0 -6 m 140 -6 l S
Q
Wie ein PDF eine Seite zeichnet: Content-Stream-Ausschnitt, in dem die Operatoren BT, Tf, Td und Tj zu den Markierungen führen, die sie malen
Operanden landen vor Operatoren und q/Q-Paare zäunen State-Änderungen ein, sodass lokale Transformationen nicht in Nachbarcontent lecken

Namen wie /F1 sind Schlüssel in die /Resources der Seite. Ein Font-Dictionary führt über seinen Font Descriptor (§9.8) zum eingebetteten Font-Programm, und ein Bild wird mit einem einzigen Do gemalt, sobald seine JPEG- oder Flate-Daten dekodiert sind. Die Rasterung kommt zuletzt. Der Renderer wendet die Transformationsmatrix auf jeden Pfad und jeden Glyphen-Umriss an, mappt Punkte auf Gerätepixel und füllt das Ergebnis mit Anti-Aliasing. Weil die Seite an jenem Punkt noch Vektordaten sind, führt das Zoomen auf 400 Prozent den Content Stream in größerem Maßstab erneut aus, statt eine Bitmap zu dehnen. Die Zielauflösung wählt der Aufrufer: 96 oder 150 Pixel pro Zoll für einen Bildschirm, 300 oder mehr für den Druck. In PDFium Component ist jene Stufe TPdf.RenderPage, das die Ausgabegröße in Pixeln entgegennimmt

uses
  System.SysUtils, Vcl.Graphics, PDFium;

// Rendert Seite 1 von FileName bei Dpi Pixel pro Zoll und sichert sie als BMP
procedure RenderFirstPage(const FileName: string; Dpi: Integer);
var
  Pdf: TPdf;
  Bmp: TBitmap;
  W, H: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := FileName;
    Pdf.Active := True;
    if not Pdf.Active then
      raise Exception.Create('Cannot open ' + FileName);
    Pdf.PageNumber := 1;        // 1-basiert
    // PageWidth und PageHeight sind in Punkt (1/72 Zoll)
    W := Round(Pdf.PageWidth * Dpi / 72);
    H := Round(Pdf.PageHeight * Dpi / 72);
    Bmp := Pdf.RenderPage(0, 0, W, H, ro0, [reAnnotations]);
    try
      Bmp.SaveToFile(ChangeFileExt(FileName, '-p1.bmp'));
    finally
      Bmp.Free;                 // die Funktionsform liefert eine neue Bitmap
    end;
  finally
    Pdf.Free;
  end;
end;

Warum liefert Copy-Paste Müll, obwohl die Seite gut aussieht?

Copy-Paste scheitert, wenn die Datei festhält, welcher Glyph zu zeichnen ist, aber nicht, für welches Zeichen er steht. Rendern braucht nur Glyphenformen aus dem eingebetteten Font-Programm. Extraktion braucht Unicode, der aus einer Standard-Kodierung oder einer /ToUnicode-CMap am Font-Dictionary kommt (§9.10.3). Ein Composite Font mit /Encoding /Identity-H schreibt rohe Glyph-IDs in den Content Stream, und ohne ToUnicode-Map haben jene IDs keine definierte Beziehung zu irgendeinem Zeichen. Der Bildschirm sieht perfekt aus, während sich die Zwischenablage mit Symbolen füllt. Subset-Fonts machen das wahrscheinlicher, denn sie behalten nur die Glyphen, die das Dokument benutzt, und sind leicht am sechsstelligen Präfix in Namen wie /BaseFont /AAAAAA+ArialMT zu erkennen (§9.6.4). Der Leitfaden zur PDF-Font-Behandlung behandelt CJK-Fonts und kaputte Extraktion in der Tiefe

Inkrementelle Updates und der Signatur-Byte-Range

Signaturen leben von der nur-anhängen-Art, wie PDFs bearbeitet werden: Ein inkrementelles Update (§7.5.6) ergänzt Objekte und einen neuen Cross-Reference-Abschnitt hinter dem letzten %%EOF, und die Strukturübersicht erklärt die /Prev-Kette und warum gelöschter Content in früheren Revisionen überlebt. Der /ByteRange eines Signature-Dictionary, geschrieben als [0 a b c], hasht alles außer dem /Contents-Hex-String, der die CMS-Signatur hält, die signierte Revision endet also bei Byte b + c, und ein Validierer baut sie nach, indem er die Datei dort abtrennt. Spätere Revisionen landen hinter jenem Punkt, während ein vollständiges Neuschreiben, ein Linearisierer oder ein Optimier-Durchlauf Bytes verschiebt, die der Signierer gehasht hat, und jede vorhandene Signatur auf einmal invalidiert

Wie inkrementelle PDF-Updates funktionieren: gestapelte Speichergenerationen mit angehängten Objekten, aufgefrischten Trailern und dem Byte-Bereich, den eine digitale Signatur abdeckt
Speicherungen hängen an statt umzuschreiben, was Validierern jede Änderung nach der Signatur offenlegt, während sanktionierte Bearbeitungen Signaturen am Leben halten

Wenn eine Delphi- oder Lazarus-Anwendung diese ganze Pipeline braucht, vom Parsen der Cross-Reference-Daten und dem Durchlaufen des Seitenbaums bis zu Rendering und Textextraktion, wickelt die PDFium Component for Delphi and Lazarus sie in die TPdf-Dokumentkomponente ein, die in den Listings oben benutzt wird, und das TPdfView-Viewer-Control zur Bildschirmanzeige