Technischer Artikel

Wie PDF-Grafik funktioniert: Content-Streams und Operatoren

Eine PDF-Seite speichert keine Pixel, und sie speichert auch keinen Baum aus Formobjekten wie SVG. Sie speichert ein Programm. Jede Linie, Kurve, Füllung und jedes platzierte Bild auf der Seite ist das Ergebnis der Ausführung einer Operatorsequenz in einem Content-Stream, von oben nach unten, gegen einen laufenden Grafikzustand. Versteht man diese eine Tatsache, hört das meiste Verhalten des Formats auf zu überraschen: warum eine Füllung einen separaten Maloperator braucht, nachdem der Pfad gebaut ist, warum Farben und Linienbreiten von einer Form in die nächste durchsickern, wenn man sie nicht einklammert, warum derselbe Zeichencode nach einer einzigen Koordinatentransformation an völlig anderen Stellen landen kann. Dies ist eine Tour durch dieses Ausführungsmodell, wie es ISO 32000 definiert: die Operatoren, denen man beim Öffnen eines Content-Streams begegnet, und die Regeln, die entscheiden, was auf der Seite erscheint

Der Content-Stream ist Postfix-Bytecode

Ein Content-Stream ist eine flache Bytefolge aus Operanden gefolgt von Operatoren. Die Operanden kommen zuerst, der Operator, der sie konsumiert, kommt zuletzt — die Umkehrung eines Funktionsaufrufs und identisch mit einer Stapelmaschine: die Zahlen pushen, dann das Verb absetzen. Es gibt keine Verschachtelung, keine Ausdruckssyntax, keine Variablen. Ein Dreiecksumriss sind fünf Zeilen davon:

100 100 m    % moveto: start a new subpath at (100, 100)
200 200 l    % lineto: add a segment to (200, 200)
300 100 l    % lineto: add a segment to (300, 100)
h            % closepath: connect back to the start
S            % stroke: paint the path outline

Die Operatoren sind mit Absicht knapp. Eine echte Seite besteht aus Tausenden davon, meist mit FlateDecode komprimiert. Der Preis dieser Kompaktheit ist, dass der Stream keine abfragbare Struktur trägt: Ein Viewer kann nicht fragen „wo ist die Überschrift auf dieser Seite“, er kann nur das Programm ausführen und sehen, wo Tinte landet. Das ist der Grundstein dafür, dass Textextraktion aus beliebigen PDFs schwierig ist

Der Ursprung liegt unten links, und Y wächst nach oben

Bevor irgendeine Koordinate Sinn ergibt, muss man wissen, wo (0, 0) liegt. PDF legt den Ursprung in die linke untere Ecke der Seite, mit nach rechts wachsendem X und nach oben wachsendem Y, gemessen in Punkten mit 72 Punkt pro Zoll (ISO 32000-2 §8.3.2). Auf einer US-Letter-Seite sitzt die Oberkante bei y = 792, nicht bei y = 0. Wer von der Bildschirmgrafik kommt, wo der Ursprung oben links liegt und Y nach unten wächst, hat das beim ersten Versuch verkehrt herum und zeichnet die erste Linie unter den Seitenrand. Die Einheit ist zudem medienunabhängig: 72 Einheiten sind ein Zoll, ob die Seite auf einem Telefonbildschirm oder einem Belichter rendert

Die meisten Seitenzeichenbibliotheken erben diese Konvention direkt. In HotPDF messen zum Beispiel TextOut und die Pfadaufrufe alle von unten links in Punkten, ein Wert nahe der Seitenhöhe setzt Inhalt also nach oben:

// HotPDF, Delphi: y measured from the bottom edge upward, in points
Pdf.CurrentPage.SetLineWidth(2.0);
Pdf.CurrentPage.MoveTo(100, 700);   // near the top of the page
Pdf.CurrentPage.LineTo(300, 700);
Pdf.CurrentPage.Stroke;             // emits the moveto/lineto/stroke operators

Diese Aufrufsequenz kompiliert exakt zu den Operatoren m, l und S oben. Die Bibliothek ist eine Schreibkraft für den Content-Stream, nichts weiter, und zu wissen, was sie ausgibt, lässt Sie über die Ausgabe schlussfolgern, wenn eine Form irgendwo landet, wo Sie sie nicht erwartet haben

Erst den Pfad bauen, dann malen

PDF trennt Pfadkonstruktion von Pfadbemalung, und die Trennung ist keine Pedanterie. Man beschreibt zuerst eine Form mit Konstruktionsoperatoren, die nichts Sichtbares hinzufügen, und setzt dann einen einzigen Maloperator ab, der entscheidet, was mit dem angesammelten Pfad geschieht. Dasselbe Dreieck kann ein Umriss, eine Vollfläche oder beides sein, allein abhängig vom Verb, mit dem man endet

Die Konstruktionsoperatoren sind wenige. m beginnt einen neuen Unterpfad an einem Punkt. l fügt ein gerades Segment hinzu. c fügt eine kubische Bézierkurve aus sechs Operanden hinzu, zwei Kontrollpunkte und ein Endpunkt. re ist eine Abkürzung, die ein ganzes Rechteck aus einem Vierertupel x, y, Breite, Höhe hinzufügt. h schließt den aktuellen Unterpfad zurück zu seinem Anfang. Keiner von ihnen bringt Tinte auf die Seite; sie sammeln nur Geometrie

200 250 m                    % start the subpath
300 350 400 450 500 250 c    % cubic Bezier: two control points, then endpoint
150 200 re                   % a 150 x 200 rectangle, added as its own subpath
h                            % close

Das ursprüngliche Beispiel nutzte die inzwischen obsolete y-Variante des Kurvenoperators; c mit seinen drei expliziten Punkten ist die Form, die man in der Praxis sieht und zu der man greifen sollte. Sobald der Pfad existiert, schließt ihn ein Maloperator ab. Das Vokabular ist klein und lohnt das Auswendiglernen, denn jede Form auf jeder Seite endet mit einem davon:

  • S zeichnet den Pfadumriss mit aktueller Linienbreite und Konturfarbe nach
  • f füllt das Innere mit der aktuellen Füllfarbe und der Nonzero-Winding-Regel
  • f* füllt nach der Even-Odd-Regel, was bei selbstschneidenden Formen und Formen mit Löchern zählt
  • B füllt und konturiert in einer Operation; b schließt den Pfad zuerst
  • n malt nichts — so wird ein Pfad zu einer Clip-Region, ohne eine sichtbare Spur zu hinterlassen

Die Winding-Regel ist der Teil, den Leute falsch machen. Nonzero (f, B) zählt die vorzeichenbehafteten Kreuzungen eines Strahls vom Testpunkt und füllt überall dort, wo die Zahl nicht null ist, ein Loch bleibt also nur leer, wenn sein Unterpfad entgegen dem äußeren gewunden ist. Even-Odd (f*, B*) schaltet bei jeder Kreuzung um, unabhängig von der Richtung. Kommt eine „Donut“-Form massiv heraus, ist der innere Kreis genauso gewunden wie der äußere, und Sie kehren ihn entweder um oder wechseln zu Even-Odd

Farbe ist ein Modus, kein Parameter

Farbe in einem Content-Stream ist haftend. Man setzt eine Farbe, und sie bleibt gesetzt, bis man eine andere setzt oder einen früheren Zustand wiederherstellt — weshalb ein nicht eingeklammerter Farbwechsel stillschweigend alles danach Gezeichnete einfärbt. PDF hält zudem Füllfarbe und Konturfarbe als zwei unabhängige Einstellungen, mit Kleinbuchstaben-Operatoren für Füllung und Großbuchstaben für Kontur. Die Gerätefarbräume haben jeweils ihre eigene Kurzform:

0.5 g                % DeviceGray fill, mid gray (0 = black, 1 = white)
0.2 0.6 0.8 rg       % DeviceRGB fill
0.8 0.2 0.1 RG       % DeviceRGB stroke (uppercase = stroke)
0.2 0.8 0.0 0.1 k    % DeviceCMYK fill

DeviceRGB passt zur Bildschirmausgabe, DeviceCMYK erwartet die Druckproduktion, und DeviceGray ist die kleinste Wahl für monochromen Inhalt. Die Geräteräume sind bequem, aber unkalibriert: Dasselbe RGB-Tripel kann auf zwei Monitoren unterschiedlich rendern, das Problem, für dessen Lösung ICC-basierte Farbräume und PDF/A-Output-Intents existieren. Für farbkritische Arbeit wählt man mit cs und CS einen kalibrierten Raum und setzt Komponenten mit sc und scn, aber für gewöhnliche Dokumente tragen die Gerätekurzformen die Last. Eine Bibliothek verpackt diese in typisierte Aufrufe. HotPDF etwa nimmt ein einzelnes TColor und gibt die passenden Operatoren aus:

Pdf.CurrentPage.SetRGBFillColor(clRed);
Pdf.CurrentPage.Rectangle(100, 100, 200, 150);  // x, y, width, height
Pdf.CurrentPage.Fill;

Pdf.CurrentPage.SetRGBFillColor(RGB(0, 255, 0));
Pdf.CurrentPage.Circle(150, 400, 50);           // x, y, radius
Pdf.CurrentPage.Fill;

Der Grafikzustand und der q/Q-Stapel

Alles, was nicht der Pfad selbst ist, lebt im Grafikzustand: aktuelle Transformationsmatrix, Füll- und Konturfarben, Linienbreite, Strichmuster, Clip-Region, Alpha. Der Zustand ist global und veränderlich, der einzig sichere Weg zu einer lokalen Änderung ist also, das Ganze zu sichern, zu modifizieren, zu zeichnen und zurückzurollen. Genau das tun q und Q. q legt eine Kopie des aktuellen Zustands auf einen Stapel; Q nimmt sie herunter und verwirft jede Änderung seit dem zugehörigen q

q                    % save the entire graphics state
2 0 0 2 100 100 cm   % concatenate a transform: scale 2x, translate to (100,100)
0.8 g                % gray fill, scoped to this block
% ... draw scaled, gray content ...
Q                    % restore: transform and color revert

Unbalancierte q und Q sind eine häufige Art, wie ein handgebauter oder zusammengesetzter Content-Stream schiefgeht. Ein verirrtes q ohne passendes Q lässt den Stapel tief zurück, wenn die Seite endet; ein überzähliges Q lässt ihn leerlaufen. So oder so kann ein Viewer einen alten Clip oder eine alte Transformation in Kraft halten, und Inhalt verschwindet oder landet am falschen Ort. Wenn Grafik ohne Grund verschwindet, den der Pfad erklären könnte, prüfen Sie zuerst den Zustandsstapel

Die CTM transformiert jede Koordinate

Die aktuelle Transformationsmatrix sitzt zwischen den Zahlen in Ihren Operatoren und der tatsächlichen Seite. Jede Koordinate wird vor dem Zeichnen mit der CTM multipliziert, eine Änderung der Matrix ändert also, wo und wie alles nachfolgende Zeichnen erscheint, ohne eine einzige Pfadkoordinate anzufassen. Der Operator cm konkateniert eine neue Matrix auf die aktuelle und nimmt sechs Operanden, die auf die affine Matrix [a b c d e f] abbilden:

1 0 0 1 100 50 cm        % translate by (100, 50): e and f carry the offset
2 0 0 1.5 0 0 cm         % scale x by 2, y by 1.5: a and d are the scale factors
0.707 0.707 -0.707 0.707 0 0 cm   % rotate 45 degrees (cos/sin in a, b, c, d)

Zwei Dinge stolpern Leute. Erstens komponiert cm, statt zu ersetzen, Transformationen akkumulieren sich also und die Reihenfolge zählt: Erst skalieren, dann verschieben ist nicht dasselbe wie erst verschieben, dann skalieren. Zweitens drehen und skalieren Rotation und Skalierung um den aktuellen Ursprung, nicht um das Zentrum Ihrer Form; um etwas an Ort und Stelle zu drehen, verschiebt man es zum Ursprung, dreht und verschiebt zurück, alles in q/Q eingefasst. Dieselbe Matrix ist es, die Bilder platziert, das letzte sehenswerte Stück

Bilder und wiederverwendbarer Inhalt sind XObjects

Rasterbilder leben nicht inline im Content-Stream. Sie werden als Bild-XObjects gespeichert, externe Objekte mit eigenem Dictionary, das Breite, Höhe, Bittiefe, Farbraum und Kompressionsfilter beschreibt, und der Content-Stream referenziert sie nur. Ein JPEG-gestütztes Foto deklariert sich so:

/Photo <<
  /Type /XObject
  /Subtype /Image
  /Width 640
  /Height 480
  /BitsPerComponent 8
  /ColorSpace /DeviceRGB
  /Filter /DCTDecode        % the image data is a JPEG stream
>>

Ein Bild-XObject zeichnet in das Einheitsquadrat: Es belegt immer die Region von (0, 0) bis (1, 1) im Benutzerraum. Man übergibt ihm weder Position noch Größe. Stattdessen setzt man die CTM so, dass dieses Einheitsquadrat auf das gewünschte Rechteck abbildet, und ruft es dann mit Do auf. Deshalb ist das Platzieren eines Bildes immer eine Transformation gefolgt von einem Aufruf, eingefasst in ein Sichern/Wiederherstellen, damit die Skalierung nicht in die nächste Operation durchsickert:

q
640 0 0 480 50 300 cm    % map the unit square to a 640x480 box at (50, 300)
/Photo Do                % paint the image XObject
Q

Derselbe Do-Mechanismus treibt Form-XObjects an, die ein wiederverwendbares Grafikstück, ein Logo oder einen wiederholten Stempel, als eigenen Content-Stream mit Begrenzungsrahmen halten. Einmal definieren, viele Male mit anderer CTM aufrufen, und die Bytes erscheinen nur einmal in der Datei. Die meisten Bibliotheken verbergen das hinter einem einzigen Platzierungsaufruf: HotPDF registriert eine Bitmap mit AddImage und platziert sie mit ShowImage, das explizit x, y, Breite und Höhe nimmt, statt Sie die Matrix von Hand bauen zu lassen:

var
  Bmp: TBitmap;
  ImgIndex: Integer;
begin
  Bmp := TBitmap.Create;
  try
    Bmp.LoadFromFile('logo.bmp');
    ImgIndex := Pdf.AddImage(Bmp, icFlate);
    // x, y (bottom-left), width, height, rotation angle
    Pdf.CurrentPage.ShowImage(ImgIndex, 50, 300, 200, 150, 0);
  finally
    Bmp.Free;
  end;
end;

Unter dieser einen Zeile schreibt die Bibliothek das Bild-XObject-Dictionary, setzt die CTM zum Dimensionieren und Positionieren des Einheitsquadrats und gibt Do aus. Das Modell darunter ist das, was man kennen sollte, denn es erklärt jedes merkwürdige Ergebnis: Ein verzerrtes Bild ist eine CTM mit ungleichen Skalierungsfaktoren, ein auf vierzig Seiten identisches Logo ist ein Form-XObject, vierzig Mal aufgerufen, und ein kopfstehend gerendertes Bild ist ein Vorzeichenwechsel in der Matrix, keine korrupte Datei

Wohin das führt

Das Grafikmodell ist klein, sobald man seine Form sieht. Ein Content-Stream ist Postfix-Bytecode, der gegen einen veränderlichen Zustand läuft; Koordinaten beginnen unten links und durchlaufen die CTM; Pfade werden lautlos gebaut und mit einem bewussten Operator gemalt; Farb- und Linieneinstellungen bleiben bestehen, bis man sie mit q/Q einklammert; Bilder und wiederverwendbare Grafiken sind XObjects, platziert durch Transformation eines Einheitsquadrats. Fast jedes verwirrende Rendering-Ergebnis reduziert sich auf eine dieser fünf Regeln. Wenn Sie sehen wollen, wie diese Grafikoperatoren im größeren Objektmodell sitzen, in den Seiten-Dictionaries und der Querverweistabelle, die auf sie zeigen, behandelt der technische Überblick über die PDF-Dateistruktur diese Schicht, und ein einfaches PDF von Grund auf bauen geht die Bytes von Anfang bis Ende durch. Das Textzeichnen lebt in seiner eigenen Operatorfamilie und hat seine eigenen Fallstricke, behandelt im Begleitartikel über PDF-Text- und Schriftbehandlung

Die hier gezeigten Delphi-Zeichenaufrufe, MoveTo, LineTo, Stroke, Rectangle, Fill, SetRGBFillColor, AddImage und ShowImage, sind Teil der HotPDF Component für Delphi und C++Builder, die diese Content-Stream-Operatoren für Sie ausgibt