Technischer Artikel

PDF-Text-Advance und q/Q-Clip-Restore im Delphi-Renderer

Ein Content Stream, der /F1 1 Tf setzt, dann 12 0 0 12 72 700 Tm, dann (Hello world) Tj zeigt, ist völlig legal. Ein handgeschriebener Renderer zeichnet alle elf Zeichen oft auf gut fünfeinhalb Punkte — ein dunkler Klecks am linken Rand, wo ein 66-Punkte-Wort stehen sollte. Die Arithmetik hinter dem Klecks ist eine Zeile lang. ISO 32000-1 §9.4.4 definiert jeden Glyph-Advance im Text Space, also muss die Verschiebung durch die Textmatrix, bevor sie die Seite erreicht, und ein Clip, der in q ... Q gesetzt wird, gehört genauso zum Graphics State wie die Linienbreite

Warum kollabiert Text zu einem Klumpen, wenn eine PDF Tf 1 nutzt?

Text kollabiert, weil der Renderer eine Text-Space-Distanz direkt auf die Translationskomponente von Tm addiert, was nur funktioniert, solange Text Space und User Space dieselbe Skalierung haben. Viele reale Produzenten schreiben die Font-Größe als 1 und tragen die echte Größe in der Textmatrix. Unter /F1 1 Tf und 12 0 0 12 72 700 Tm schiebt ein Glyph von 500 Einheiten Breite 0,5 im Text Space weiter. Tm skaliert das auf 6 Punkte auf der Seite. Ein Renderer, der Tm.e := Tm.e + Adv ausführt, bewegt den Stift 0,5 Punkte. Jeder Glyph landet dann ein Zwölftel eines Zeichens hinter dem vorherigen, und ein Viewer, der §9.4.4 folgt, zeigt dieselbe Datei als normalen Fließtext

Warum Text unter Tf 1 im HotPDF-Renderer kollabiert: bei 12 0 0 12 72 700 Tm muss ein Glyph von 500 Einheiten 0,5 Text-Space-Einheiten weit rücken, was Tm auf 6 Punkte skaliert, während 0,5 direkt auf Tm.e eine Fließtextzeile als Schmiererei mit je einem Zwölftel eines Zeichens pro Glyph rendert
Produzenten, die die Font-Größe in der Textmatrix kodieren, legen die Tm.e-Abkürzung sofort offen: jeder Glyph landet ein Zwölftel eines Zeichens hinter dem vorherigen, was Ausgabe mit der echten Größe in Tf nie verrät

Die Abkürzung überlebt lange, weil die meisten Generatoren, mit denen man testet, /F1 12 Tf und eine unskalierte Tm schreiben, und in dem Fall sind Text Space und User Space wirklich gleich groß. Hier die naive Version in voller Länge, mit dem Content Stream, der sie bricht, als Kommentar obendrauf:

// Content Stream eines Produzenten, der die Größe in Tm kodiert, nicht in Tf:
//   BT
//   /F1 1 Tf
//   12 0 0 12 72 700 Tm
//   [(Hel) 30 (lo) -250 (world)] TJ
//   ET

// Naiver Advance: eine Text-Space-Distanz, auf Tm.e addiert, als wäre sie User Space
Adv := W * FontSize / 1000;                  // 0.5 für einen Glyph von 500 Einheiten
if (HorizScale <> 0) and (HorizScale <> 100) then
  Adv := Adv * HorizScale / 100;             // Th nur auf die Breite
Adv := Adv + CharSpace;                      // Tc wird nicht mit Th skaliert
if Code = 32 then
  Adv := Adv + WordSpace * FontSize / 1000;  // Tw fälschlich mit Tfs skaliert
Tm.e := Tm.e + Adv;                          // ignoriert Tm.a, Tm.b, Tm.c, Tm.d

// Naive TJ-Korrektur: kein Th, und wieder nur Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;

Die Tm.e-Zeile ist der laute Fehler. Die anderen sind leiser, und sie zeigen sich auf anderen Dateien. Word Spacing Tw ist bereits in unskalierten Text-Space-Einheiten ausgedrückt, daher macht FontSize / 1000 aus einem 3 Tw unter Tf 12 genau 0,036 Einheiten. Ein im Blocksatz gesetzter Absatz verliert fast seinen ganzen Wortzwischenraum. Horizontal Scaling Th muss auch auf Tc und Tw angewendet werden und auf die TJ-Kerning-Zahl. Der Test Code = 32 feuert außerdem bei jedem Byte mit Wert 32 innerhalb eines Zweibyte-Codes, was zufällige Lücken durch CJK-Text verteilt. Text-State-Fehler wie diese scheitern selten laut: Wie die Operand-Index- und Resource-Name-Fallen, die Tc, Tw und Tz nullen können, bevor ein einziger Glyph gezeichnet ist, liefern sie plausible Seiten vom einen Produzenten und kaputte vom nächsten

Wie definiert ISO 32000-1 §9.4.4 das Glyphen-Advance?

ISO 32000-1 §9.4.4 berechnet den Advance vollständig im Text Space und wendet ihn als Translationsmatrix auf die Textmatrix an, also kommen Skalierung, Rotation und Scherung aus Tm und nie aus dem Advance selbst. Für horizontale Schrift gilt tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, wobei w0 die Glyph-Breite in Tausendstel em ist, Tj die TJ-Korrektur und Th Tz geteilt durch 100. Die neue Matrix ist [1 0 0 1 tx 0] × Tm. Ausgeschrieben addiert dieses Produkt a·tx zu e und b·tx zu f, weshalb eine Translation im Text Space nie eine schlichte Addition auf e sein kann, außer a ist 1 und b ist 0. Nach §9.3.3 gilt Tw nur für den Einbyte-Zeichencode 32

ISO 32000-1 9.4.4 Glyph-Advance im HotPDF-Renderer: tx wird im Text Space aus w0, Tj, Tfs, Tc, Tw und Th berechnet, dann über die a-, b-, c- und d-Koeffizienten der Textmatrix angewendet, sodass Td, TD, TJ und unsichtbarer Text sich alle nach derselben Regel bewegen
Den Advance direkt auf Tm.e zu addieren funktioniert nur, wenn Text Space gleich User Space ist; der Weg über die Matrixkoeffizienten hält skalierten, rotierten und gescherten Text korrekt

Die HotPDF Delphi Component hält diese Formel an einem Ort. Die Unit HPDFContentStream exportiert HPDFTextGlyphDisplacement für den Advance pro Glyph und HPDFTextAdjustmentDisplacement für eine TJ-Zahl, und der Seiten-Renderer wie der Textextraktions-Interpreter rufen sie auf, sodass eine Seite, die korrekt rendert, auch Glyph-Positionen meldet, die zu dem passen, was man sieht. Wer einen eigenen Content-Stream-Verbraucher auf dem Parser der Bibliothek aufsetzt, kann dieselben zwei Routinen aufrufen und den Matrixschritt selbst liefern:

uses
  HPDFContentStream;  // HPDFTextGlyphDisplacement, HPDFTextAdjustmentDisplacement

type
  // Eigener Textmatrix-Record, kein HotPDF-Typ
  TTextMatrix = record
    a, b, c, d, e, f: Double;
  end;

// Eigener Helper: [1 0 0 1 Tx Ty] x Tm, durch a, b, c und d hindurch
procedure TranslateInTextSpace(var M: TTextMatrix; Tx, Ty: Double);
begin
  M.e := M.e + M.a * Tx + M.c * Ty;
  M.f := M.f + M.b * Tx + M.d * Ty;
end;

// W0 ist die Glyph-Breite in 1/1000 em; Tz ist der Tz-Operand (100 = keine Skalierung)
procedure AdvanceAfterGlyph(var Tm: TTextMatrix; W0: Double; Code: Cardinal;
  TwoByteCodes: Boolean; Tfs, Tc, Tw, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextGlyphDisplacement(W0 * Tfs / 1000, False,
    (Code = 32) and not TwoByteCodes,  // Tw nur auf dem Einbyte-Leerzeichen
    Tc, Tw, Tz, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);
  // Tf 1, Tm 12 0 0 12 72 700, W0 = 500: Tx = 0.5 und Tm.e wächst um 6
end;

procedure ApplyTJNumber(var Tm: TTextMatrix; Adjustment, Tfs, Tz: Double);
var
  Tx, Ty: Double;
begin
  HPDFTextAdjustmentDisplacement(Adjustment, Tfs, Tz, False, Tx, Ty);
  TranslateInTextSpace(Tm, Tx, Ty);  // Th gilt auch beim Kerning
end;

Im Renderer treibt derselbe Matrixschritt jeden Operator an, der die Textposition bewegt. Td, TD, T*, ' und " verschieben die Zeilenmatrix über ihre Koeffizienten, daher bewegt 0 -1.2 Td unter einer 12×-Textmatrix die Grundlinie 14,4 Punkte nach unten statt 1,2. Glyphen, die nie gemalt werden, nehmen denselben Weg. Render Mode 3, der unsichtbare Modus, den OCR-Textschichten nutzen, und Text in verborgenem Optional Content schieben Tm genau wie gemalter Text weiter. Ihnen eine private Kopie der Arithmetik zu geben, ist der klassische Fehler, denn dann startet jeder sichtbare Text, der auf einen unsichtbaren Lauf im selben BT-Block folgt, an einer leicht anderen Position

Den Glyph selbst platzieren

Die Glyph-Platzierung folgt derselben Logik. Steht kein eingebetteter Outline zur Verfügung und der Renderer fällt auf GDI TextOutW zurück, baut er die volle Glyph-Matrix aus CTM × Tm × Rise × em-Skalierung auf, Th eingeschlossen. Die Matrix geht über SetWorldTransform im GM_ADVANCED-Modus hinein, innerhalb eines SaveDC-/ RestoreDC-Paares. Der GDI-Font wird mit fester 1000-Einheiten-Höhe erzeugt, und die World-Transform erledigt die gesamte Größenanpassung. Rotierter und gescherter Text behält deshalb seine Orientierung, statt aufrecht an einem transformierten Ursprung gezeichnet zu werden. Render-Modes mit Stroke setzen die World-Transform vor dem Stroke zurück, denn eine gegen eine 1000-Einheiten-Designgröße gemessene Stiftbreite würde sonst fast auf null schrumpfen. Vertikale Schrift ist die eine bewusste Asymmetrie: Ein WMode-1-Font rückt die y-Achse hinab um sein vertikales Maß, Tc und Tw gelten entlang dieser Achse, und Th gilt gar nicht

Was q/Q in einem PDF-Graphics-State eigentlich speichert

ISO 32000-1 §8.4.2 listet den aktuellen Clipping-Pfad als Teil des Graphics State, also muss Q den Clip genau so wiederherstellen, wie er beim passenden q stand. Das klingt offensichtlich. In einem GDI-basierten Renderer übersieht man es leicht, denn CTM, Farben, Linienparameter und Text-State leben im eigenen State-Stapel des Renderers, während der Clip im Device Context wohnt — außerhalb dieses Stapels. Das Kopieren des numerischen States stellt alles außer dem Clip wieder her. Ein W n in einem q ... Q-Block beschnet dann jede spätere Operation auf der Seite, und das Symptom sieht aus wie fehlender Content weit weg vom Operator, der ihn verursacht hat

Form XObjects öffnen eine zweite Route zum selben Fehler. §8.10 gibt einem Form ein implizites Save und Restore um seinen Content und beschneidet es auf seine /BBox, und reale Form-Inhalte lassen ihr eigenes q und Q mitunter unbalanciert, obwohl die Specification das Paaren verlangt. HotPDF sichert den Clip und den Device Context, bevor es ein Form ausführt, und wirft nach dem Form alle offengebliebenen Clip-Frames weg und stellt den Device Context wieder her, sodass jede gesicherte Region exakt einmal freigegeben wird. Ein wildes Q im Form, das den State des Aufrufers wegpoppen würde, wird abgelehnt statt angewendet. Mit RenderFallbackPolicy auf rfpReport erscheint die Ablehnung in den Diagnostics, was es leicht macht, einen schludrigen Produzenten von einem Renderproblem zu unterscheiden:

// Diags: THPDFRenderFallbackDiagnostics (deklariert in HPDFPageRenderer)
Pdf.RenderFallbackPolicy := rfpReport;
Bmp := Pdf.RenderLoadedPageToBitmap(PageIndex, 150);
try
  Diags := Pdf.GetLastRenderFallbackDiagnostics;
  for J := 0 to High(Diags) do
    if (Diags[J].Kind = rfkInvalidObject) and (Diags[J].OperatorName = 'Q') then
      Log.Add(Format('page %d: unbalanced Q refused - %s',
        [PageIndex + 1, string(Diags[J].Reason)]));
finally
  Bmp.Free;  // Free ist auf nil gefahrlos
end;

Den Clip erst erfassen, wenn ein Frame ihn ändert

Die geradlinige Lösung ist, bei jedem q die GDI-Clip-Region zu lesen und bei jedem Q zurückzusetzen. Sie ist korrekt. Sie ist auch teuer ausgerechnet auf den Seiten, die q am meisten benutzen, denn Generatoren wickeln gern jede kleine Zeichnung in q 1 0 0 1 x y cm ... Q, nur um den Ursprung zu verschieben. Jedes dieser Paare würde dann eine GDI-Region erzeugen, den Clip hineinkopieren und sie wieder löschen. Rendern mehrere Seiten parallel, konkurrieren die Threads am Ende um GDI-Region-Objekte statt zu rasterisieren, und der Geschwindigkeitsgewinn aus der parallelen Render-Pipeline schrumpft entsprechend

Träges GDI-Clip-Erfassen im HotPDF-Renderer: q merkt sich nur Stapeltiefe und Zielfläche, die Clip-Region wird erst gelesen, kurz bevor W, n, ein Text-Clip oder der Form-Eintritt das Clipping ändern, und Q stellt sie wieder her und gibt sie frei, sodass Seiten voller q-cm-Q-Paare keine GDI-Regionen allokieren
Bei jedem q eine GDI-Region anzulegen lässt Render-Threads um Region-Objekte konkurrieren, daher wird der Clip erst erfasst, wenn ein Operator gerade das Clipping ändern will

HotPDF schiebt den teuren Teil auf. Ein q merkt sich nur die Stapeltiefe und die Fläche, zu der es gehört, in einem Frame-Array, das von 16 an verdoppelnd wächst und über die Seite hinweg wiederverwendet wird, sodass eine Folge numerischer Transformationen gar kein GDI-Objekt allokaliert. Der Clip wird erfasst, sobald etwas in diesem Frame im Begriff ist, ihn zu ändern: Pfad-Malen oder n mit hängendem W oder W*, ein Pattern-Fill, der Clip, den Text-Render-Modes 4 bis 7 hinterlassen, oder der Eintritt in ein Form. Bei Q setzt ein erfasster Frame seine Region zurück und gibt sie frei, und ein Frame, der den Clip nie änderte, poppt nur den numerischen State. Die gewöhnliche Seite allokaliert dann gar keine Region, und eine Seite mit Clips zahlt genau eine Region pro echter Clip-Änderung. Wer ein eigenes GDI-Device für einen PDF-Interpreter baut, passt dieselbe Gestalt in ein paar Dutzend Zeilen:

// Ein Sketch für ein eigenes GDI-Device; nichts davon ist HotPDF-API
// braucht Windows, SysUtils und Math in der uses-Klausel
type
  TClipFrame = record
    Region: HRGN;
    Captured: Boolean;
  end;

procedure TMyDevice.PushState;            // q: überhaupt kein GDI-Aufruf
begin
  if FCount = Length(FFrames) then
    SetLength(FFrames, Max(16, Length(FFrames) * 2));
  FFrames[FCount].Region := 0;
  FFrames[FCount].Captured := False;
  Inc(FCount);
end;

procedure TMyDevice.BeforeClipChange;     // aufrufen vor W n, W f, Text-Clip, Form-BBox
var
  R: HRGN;
  Res: Integer;
begin
  if (FCount = 0) or FFrames[FCount - 1].Captured then Exit;
  R := CreateRectRgn(0, 0, 0, 0);
  if R = 0 then RaiseLastOSError;
  Res := GetClipRgn(FDC, R);              // 1 kopiert, 0 kein Clip, -1 Fehler
  if Res <= 0 then
  begin
    DeleteObject(R);
    R := 0;
    if Res < 0 then RaiseLastOSError;
  end;
  FFrames[FCount - 1].Region := R;
  FFrames[FCount - 1].Captured := True;
end;

procedure TMyDevice.PopState;             // Q
begin
  if FCount = 0 then Exit;                // unbalanciertes Q: ignorieren
  Dec(FCount);
  if FFrames[FCount].Captured then
    SelectClipRgn(FDC, FFrames[FCount].Region);  // Region 0 entfernt den Clip
  if FFrames[FCount].Region <> 0 then
    DeleteObject(FFrames[FCount].Region);
end;

Zwei Details in diesem Sketch tragen die Hauptlast. Ein GetClipRgn-Rückgabewert 0 ist kein Fehler: Er heißt, der Device Context hatte zu dem Zeitpunkt keinen Clip. Auf Q eine Null-Region an SelectClipRgn zu übergeben, ist genau das, was einen Clip entfernt, der beim passenden q nicht existierte. Die 0 als Fehlschlag zu behandeln, lässt den inneren Clip stehen. Das andere Detail ist das Captured-Flag, das verhindert, dass eine zweite Clip-Änderung im selben Frame die gesicherte Region mit einer bereits verengten überschreibt

Wo der gesicherte Clip und die Advance-Regel enden

Der gesicherte Clip ist eine GDI-Region in Device-Pixeln. Er ist exakt für das Bitmap, das gerade gerendert wird, und bedeutungslos für jede andere Fläche, daher merkt sich jeder Frame auch den Device Context, aus dem er genommen wurde, und die Wiederherstellung wird übersprungen, wenn der Context gewechselt hat, etwa während eine Transparency Group in ihr eigenes Layer-Bitmap rendert

Auf der Text-Seite platziert die Advance-Regel jeden Glyph anhand der Breite, die sie bekommt. Lässt ein Simple Font sein /Widths-Array weg und ist kein eingebettetes Font-Programm verfügbar, kann der Advance nur so gut sein wie der Breiten-Fallback, und §9.4.4 sagt nichts dazu, woher diese Breite kommt. Regressionstests dafür leben auf Dateien, die simple generierte Dokumente fast nie enthalten: eine mit Tf 1 und skaliertem Tm, eine mit ungleich null Tz und Tw auf Blocksatztext, und eine mit einem W n-Clip in q ... Q, gefolgt von Content außerhalb des beschnittenen Bereichs. Ein korrektes Rendern der dritten zeigt den äußeren Content vollständig

Aus dem Anwendungscode heraus braucht keines davon eine Einrichtung: Der Aufruf ist der aus dem Rendern einer PDF-Seite in ein Bitmap, und beide Regeln gelten für jede Seite, die Sie so rendern. Unterstützte Delphi- und C++Builder-Versionen und Lizenzdetails stehen auf der HotPDF-Delphi-PDF-Component-Seite