Der HotPDF Delphi Component Seiten-Renderer rückt Text jetzt vor, indem er jede Glyphen-Verschiebung im Text Space berechnet, tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, wie ISO 32000-1 §9.4.4 es definiert, und dann die Textmatrix durch ihren linearen Teil mit HPDFTranslateTextMatrix bewegt. Clipping wird pro q-Frame gesichert und auf Q wiederhergestellt, aber eine GDI-Region wird erst dann eingefangen, wenn dieser Frame den Clip tatsächlich ändert. Beide Fixes landeten in HotPDF 2.754.0, und beide kamen aus realen Seiten, die mit kollabierten Wörtern renderten oder mit Clip-Regionen, die über ihr Q hinausliefen. Der erste Bug ist Arithmetik, die richtig aussieht, bis ein Producer seine Font-Größe in die Matrix schreibt. Der zweite ist ein Korrektheits-Fix, der uns beinahe den Parallel-Render-Speedup gekostet hätte, und wie wir das Tempo zurückbekommen haben, ist gut zu wissen, wenn Sie irgendein GDI-basiertes PDF-Gerät schreiben
Warum kollabiert Text zu einem Klumpen, wenn eine PDF Tf 1 nutzt?
Weil der alte Advance-Code eine Text-Space-Distanz direkt zur Translationskomponente von Tm addierte, als hätten Text Space und User Space immer denselben Maßstab. Reichlich reale Producer setzen die Font-Größe mit Tf auf 1 und tragen die echte Größe in der Textmatrix. Mit /F1 1 Tf und 12 0 0 12 72 700 Tm rückt eine 500 Einheiten breite Glyphe 0,5 im Text Space vor, was einmal von Tm skaliert 6 Punkte auf der Seite sind. Der alte Renderer führte Tm.e := Tm.e + Adv aus und bewegte den Stift um 0,5 Punkte. Jede Glyphe landete ein Zwölftel eines Zeichens hinter der vorherigen, eine Textzeile renderte also als dunkler Schmierer am linken Rand, während dieselbe Datei in jedem anderen Viewer perfekt aussah
// Content-Stream von einem Producer, 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
// Alter Advance (vereinfacht): Distanz auf Tm.e addiert, als wäre es User Space
Adv := W * FontSize / 1000; // 0.5 für eine 500-Einheiten-Glyphe
if (HorizScale <> 0) and (HorizScale <> 100) then
Adv := Adv * HorizScale / 100; // Th nur auf die Breite
Adv := Adv + CharSpace; // Tc 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
// Alte TJ-Anpassung: kein Th, und wieder nur Tm.e
Tm.e := Tm.e - NumValue * FontSize / 1000;
Der Tm.e-Kurzschluss war nicht der einzige Defekt in diesem Block. Word Spacing Tw ist in unskalierten Text-Space-Einheiten ausgedrückt, der alte Code multiplizierte es aber mit FontSize / 1000, bei Tf 12 verlor also eine ausgerichtete Zeile fast ihren ganzen Zwischenwort-Abstand. Horizontale Skalierung Th griff auf die Glyphenbreite, aber nicht auf Tc oder Tw, und die TJ-Kerning-Anpassung übersprang sie komplett. Der Nicht-Zeichen-Pfad, der Render-Mode-3-unsichtbaren Text vorrückt – die Art, die OCR-Textebenen nutzen – und Text in verborgenem Optional Content trug eine private Kopie derselben Arithmetik, also startete alles, was nach einem unsichtbaren Zug gezeichnet wurde, an der falschen Position. Text-State-Bugs in einem Renderer scheitern selten laut: Wie die Operand-Index- und Ressourcennamen-Bugs, die einst Tc, Tw und Tz ohne einen einzigen Fehler auf null setzten, produzierten sie plausible Seiten auf der eigenen Ausgabe der Bibliothek und gingen nur an Dateien anderer Producer kaputt
Wie definiert ISO 32000-1 §9.4.4 das Glyphen-Advance?
ISO 32000-1 §9.4.4 definiert das Advance vollständig im Text Space und wendet es als Translationsmatrix auf die Textmatrix an, die Antwort ist also, tx zuerst zu berechnen und Tm Skalierung, Rotation und Scherung machen zu lassen. Für horizontale Schreibweise gilt tx = ((w0 − Tj/1000) × Tfs + Tc + Tw) × Th, wobei w0 die Glyphenbreite in Tausendstel eines em ist, Tj die TJ-Anpassung, und Th ist Tz geteilt durch 100. Die neue Tm ist [1 0 0 1 tx 0] × Tm, was in HotPDF der Helfer HPDFTranslateTextMatrix ist: Er addiert X und Y über die Matrixkoeffizienten a, b, c und d, statt direkt auf e und f zu schreiben. Per §9.3.3 greift Tw nur auf den Single-Byte-Zeichencode 32, Multibyte-CID-Codes holen sich also auf dem horizontalen Pfad nie Word Spacing. Derselbe Helfer treibt jetzt Td, TD, T*, die Operatoren ' und ", TJ-Anpassungen und den Hidden-Text-Pfad an, was bedeutet: Eine einzige Funktion besitzt die Regel
procedure HPDFTranslateTextMatrix(var Matrix: THPDFAffineMatrix; X, Y: Double);
begin
Matrix.e := Matrix.e + Matrix.a * X + Matrix.c * Y;
Matrix.f := Matrix.f + Matrix.b * X + Matrix.d * Y;
end;
// Horizontales Glyphen-Advance, ISO 32000-1 9.4.4
W := HPDFFontCharWidth(F, Code);
Adv := W * State.Text.FontSize / 1000 + State.Text.CharSpace;
if (Code = 32) and not F.CID2Byte then
Adv := Adv + State.Text.WordSpace; // Tw im Text Space, unskaliert
Adv := Adv * State.Text.HorizScale / 100; // Th greift auf die ganze Summe
HPDFTranslateTextMatrix(Tm, Adv, 0);
// TJ-Zahl-Element: derselbe Space, dasselbe Th
Adjustment := -Items[I].NumValue * State.Text.FontSize / 1000;
HPDFTranslateTextMatrix(State.Text.Tm, Adjustment * State.Text.HorizScale / 100, 0);
Die Glyphenplatzierung musste derselben Logik folgen. Wenn keine eingebettete Outline verfügbar ist und der Renderer auf GDI TextOutW ausweicht, baut er jetzt die volle Glyphenmatrix aus CTM × Tm × Rise × em-Scale, eingeschlossen Th, und installiert sie mit SetWorldTransform im GM_ADVANCED-Modus innerhalb eines SaveDC-/RestoreDC-Paars. Der GDI-Font wird mit fester 1000-Einheiten-Höhe erzeugt, und die Transform macht die Größenanpassung, rotierter und gescherter Text behält also seine Orientierung, statt aufrecht an einem transformierten Ursprungspunkt gezeichnet zu werden. Vertikale Schreibweise ist die eine bewusste Asymmetrie: Ein WMode-1-Font rückt die y-Achse hinab um seine vertikale Metrik vor, und horizontale Skalierung greift auf dieser Achse nicht
Was sichert q/Q tatsächlich im PDF-Graphics-State?
ISO 32000-1 §8.4.2 listet den aktuellen Clipping-Pfad als Teil des Graphics State, Q muss den Clip also exakt so wiederherstellen, wie er beim passenden q war, nicht nur die numerischen Parameter. HotPDF führte bereits einen Graphics-State-Stack mit CTM, Farben, Linienparametern und Text-State, aber GDI hält den Clip im Device Context, außerhalb dieses Stacks. Eine Kopie des numerischen Zustands stellte also alles außer dem Clip wieder her, und ein mit W n innerhalb eines q ... Q-Blocks installierter Clip schnitt jede spätere Operation auf der Seite weiter zu. Form XObjects legten eine zweite Route zum selben Scheitern an, denn §8.10 gibt einer Form ein implizites Save und Restore um ihren Inhalt, und realer Formularinhalt hinterlässt manchmal seine eigenen q-Operatoren unausgeglichen, obwohl die Spezifikation verlangt, dass sie sich paaren. Der Renderer ruft jetzt CaptureClipBeforeChange und SaveDC auf, bevor er eine Form abläuft, und nach dem Ende der Form verwirft er alle gesicherten Regionen, die tiefer als die Eintrittstiefe sind, und ruft RestoreDC, jede gesicherte HRGN hat also exakt einen Freigabepfad
Träges Clip-Capturing mit THPDFSavedClipState
Der Fix, der ausgeliefert wurde, sichert einen THPDFSavedClipState-Record pro q, schiebt aber den teuren Teil auf, bis der Frame den Clip zum ersten Mal ändert. Der Record hält das Region-Handle, die Stack-Tiefe, zu der er gehört, den Device Context, aus dem er genommen wurde, und ein Captured-Flag. DevPushState füllt nur Tiefe und DC ein und wächst das Frame-Array durch Verdoppeln von 16, ein Content-Stream voller q 1 0 0 1 x y cm ... Q allokiert also gar kein GDI-Objekt. Operatoren, die im Begriff sind, Clipping zu ändern – gemeint ist Pfadmalerei mit einem schwebenden W oder W*, der n-Operator, Pattern-Fills und Formeintritt – rufen zuerst CaptureClipBeforeChange auf
procedure THPDFPageRenderer.CaptureClipBeforeChange;
var
Index, ClipResult: Integer;
Region: HRGN;
begin
ClearSavedClipRegions(FGSStack.Count);
Index := FSavedClipCount - 1;
if (Index < 0) or FSavedClips[Index].Captured or
(FSavedClips[Index].StackDepth <> FGSStack.Count) or
(FSavedClips[Index].DC <> FDC) then Exit; // schon gesichert, oder nicht unserer
Region := CreateRectRgn(0, 0, 0, 0);
if Region = 0 then RaiseLastOSError;
ClipResult := GetClipRgn(FDC, Region); // 0 heißt gar kein Clip
if ClipResult <= 0 then
begin
DeleteObject(Region);
Region := 0;
if ClipResult < 0 then RaiseLastOSError;
end;
FSavedClips[Index].Region := Region;
FSavedClips[Index].Captured := True;
end;
procedure THPDFPageRenderer.DevPopState;
var
Index: Integer;
begin
ClearSavedClipRegions(FGSStack.Count);
Index := FSavedClipCount - 1;
if (Index >= 0) and (FSavedClips[Index].StackDepth = FGSStack.Count) then
begin
if FSavedClips[Index].Captured and (FSavedClips[Index].DC = FDC) then
SelectClipRgn(FDC, FSavedClips[Index].Region); // Region 0 entfernt den Clip
if FSavedClips[Index].Region <> 0 then
DeleteObject(FSavedClips[Index].Region);
Dec(FSavedClipCount);
end;
FGSStack.Pop;
end;
Die gemessenen Kosten der eifrigen Version sind der Grund, warum dieses Design existiert. Die erste korrekte Implementation erzeugte und las eine GDI-Region bei jedem q, und auf Seiten, die meist aus numerischen Transforms bestehen, verbrachten die Renderer-Threads ihre Zeit damit, um GDI-Region-Objekte zu streiten, statt zu rastern. Die parallele Render-Pipeline fiel von ihrem erwarteten Gewinn auf rund das 1,13- bis 1,20-Fache des Single-Thread-Durchsatzes und scheiterte an der 1,5-fachen-Speedup-Schranke in der Benchmark-Suite. Mit trägem Einfangen und wiederverwendeter Frame-Kapazität besteht derselbe Benchmark wieder die ursprüngliche 1,5-fache-Schranke. Kleines TrueType-Glyphen-Antialiasing landete im selben Release und war der naheliegende Verdächtige, aber die Regression führte zurück zur Region-Allokation – eine gute Erinnerung daran zu messen, bevor man dem neuesten Feature die Schuld gibt
Wo liegen die Grenzen dieses Ansatzes?
Der gesicherte Clip ist eine GDI-Region in Device-Pixeln, er ist also exakt für die Bitmap, die gerade gerendert wird, und bedeutungslos für jedes andere Ziel. Deshalb notiert jeder Frame seinen Device Context, und DevPopState überspringt die Wiederherstellung, wenn der DC gewechselt hat, etwa während eine Transparency Group in ihre eigene Layer-Bitmap rendert. GetClipRgn, das null zurückgibt, ist ein legitimes Ergebnis und heißt gar kein Clip, und es mit SelectClipRgn(FDC, 0) wiederherzustellen ist genau das, was einen Clip korrekt entfernt, der beim passenden q nicht existierte. Auf der Textseite korrigiert der Fix, wohin jede Glyphe geht, aber er erfindet keine Breiten: Lässt ein Font sein /Widths-Array weg und das eingebettete Programm ist nicht verfügbar, ist das Advance weiterhin nur so gut wie der Width-Fallback. Wenn Sie diesen Bereich regressionstesten, halten Sie mindestens ein Fixture mit Tf 1 und einer skalierten Tm vor, eines mit nicht-null Tz und Tw, und eines mit einem Clip innerhalb von q ... Q, gefolgt von Inhalt außerhalb, denn keines davon taucht in Dokumenten auf, die die Bibliothek selbst erzeugt
Wenn Sie den Renderer aus Anwendungscode treiben, ändert sich nichts an dem in eine PDF-Seite in eine Bitmap rendern beschriebenen Aufrufmuster, und Seiten, die zuvor verschmierte Zeilen oder abgeschnittene Inhalte zeigten, rendern auf 2.754.0 und später schlicht korrekt. Details zur Komponente, zu unterstützten Delphi- und C++Builder-Versionen und zur Lizenzierung finden Sie auf der Produktseite der HotPDF Delphi PDF Component