Technischer Artikel

PDF-Tiling-Pattern-Rendering in Delphi mit HotPDF

Eine Schraffur, die als ein flacher grauer Block gerendert wird, ist der klassische Tiling-Pattern-Fehler. HotPDF, die native VCL-PDF-Komponente für Delphi und C++Builder, malt PatternType 1, indem der aktuelle Pfad in ein temporäres Clipping verwandelt und der Pattern-Content-Stream einmal pro sichtbarer Kachel erneut abgespielt wird, wobei die Pattern-Auswahl im Grafikstatus gehalten und durch q und Q wiederhergestellt wird

Die Symptome treten in zwei Varianten auf, und sie wirken unzusammenhängend, bis man die Ursache kennt. Eine CAD-Zeichnung verliert ihre Schnittschraffur und kommt als Vollflächenfüllung zurück, weil der Renderer das Pattern auf eine mittlere Farbe reduziert und diese gemalt hat. Oder die Schraffur läuft aus: Ein Schriftfeld, das eigentlich reines Weiß sein sollte, übernimmt die diagonalen Linien aus einer Detailansicht, die zwei Pfade zuvor gezeichnet wurde. Beides sind Probleme des Pattern-Status, und nur eines davon hat überhaupt etwas mit dem Zeichnen von Kacheln zu tun

Warum blutet ein Tiling-Pattern in den nächsten Pfad hinein?

Weil der ausgewählte Pattern-Name Teil des Grafikstatus ist, nicht eine Eigenschaft des Operators, der ihn verwendet hat. ISO 32000-1 §8.6.6.2 definiert einen Pattern-Farbraum als einen, dessen Farbwert ein an scn oder SCN übergebener Pattern-Name ist, und jede andere Komponente des Farbstatus wird von q gesichert und von Q wiederhergestellt. Der Pattern-Name muss derselben Regel folgen. HotPDF hält ihn im State-Record als FillPatternName und StrokePatternName, zusammen mit der Farbraum-Familie für Fläche und Kontur, sodass ein Q die vorherige Auswahl genauso exakt zurücksetzt wie die vorherige CTM

Speichert man diesen Namen stattdessen in einer lokalen Variablen im Operator-Dispatcher, überlebt er jedes Q im Stream. Der Fehler zeigt sich dann an unerwarteter Stelle: Ein nach dem gemusterten Pfad gezeichnetes Form-XObject erbt eine Pattern-Auswahl, die sein eigener Content-Stream nie getroffen hat, und seine Füllungen kommen schraffiert heraus. Verschachtelte Formulare verschlimmern das, weil jede Verschachtelungsebene Status auf einen Stack legt und wieder entfernt, den die versprengte Variable ignoriert. Auch das Setzen eines Nicht-Pattern-Farbraums mit cs oder CS, oder das Ausführen eines schlichten g / rg / k, muss den Pattern-Namen löschen, sonst überlebt die veraltete Auswahl den Farbraum, der ihr überhaupt erst Bedeutung gab

q
  /Pattern cs              % pattern colour space, ISO 32000-1 8.6.6.2
  /P1 scn                  % coloured tiling pattern, PaintType 1
  10 10 200 120 re f       % this rectangle is hatched
Q
0 0 300 200 re f           % must be black again, not hatched

q
  /Cs2 cs                  % [/Pattern /DeviceCMYK] array
  0 0.6 1 0 /P2 scn        % uncoloured pattern plus its underlying colour
  20 20 160 90 re f*
Q

Ein Pattern wird über ein Clipping gemalt, nie als Füllung

Das korrekte Modell ist subtraktiv: Das Geräte-Clipping wird auf die gerade gemalte Form eingeschränkt, dann läuft der Pattern-Content darin ab. HotPDF zeichnet niemals zuerst eine massive Näherung und übermalt sie, weil die massive Zwischenstufe durch die Lücken zwischen den Kacheln sichtbar wäre und mit jeder Transparenz im Kachelinhalt kollidieren würde. §8.7.3.2 beschreibt ein Tiling-Pattern als einen Content-Stream, der in festen horizontalen und vertikalen Abständen repliziert wird, und Replikation ergibt nur gegen ein Clipping Sinn, das bereits die richtige Form hat. Für Füllungen ist die Umsetzung direkt: HPDFSelectFillPathClip setzt den Polygon-Füllmodus für f*, B* und b* auf ALTERNATE und für die Nonzero-Varianten auf WINDING, baut den GDI-Pfad und schneidet ihn mit SelectClipPath in das Clipping. Genau diese eine Zeile sorgt dafür, dass eine Even-Odd-gemusterte Füllung dieselben Löcher hinterlässt wie eine Even-Odd-Vollflächenfüllung, was genau das ist, was ein donutförmiger schraffierter Bereich braucht

Konturen sind der Teil, bei dem man leicht etwas falsch macht. Ein Konturpfad hat kein Inneres, sodass das Schneiden des Pfads selbst in das Clipping eine leere Region ergibt und nichts gemalt wird. HPDFSelectStrokePathClip baut deshalb zunächst aus dem aktuellen Status einen geometrischen Stift, mit PS_GEOMETRIC, der Endkappe aus J, dem Join aus j, dem Miter-Limit aus M und PS_USERSTYLE, wenn ein Strichmuster aktiv ist, und ruft dann WidenPath auf, um die Konturlinie in eine füllbare Region umzuwandeln, bevor geclipt wird. Verhalten von Kappe, Join, Miter und Strichmuster auf einem gemusterten Konturpfad stimmen dann konstruktionsbedingt mit einer normalen Kontur überein, nicht durch eine zweite Implementierung. Zwei ehrliche Grenzen leben hier: Linienbreiten unter einer Geräteeinheit werden auf ein Pixel geklemmt, und das Strichmuster-Array wird bei sechzehn Einträgen abgeschnitten, was die von ExtCreatePen akzeptierte Obergrenze ist

Welche Kacheln sind tatsächlich sichtbar?

Der sichtbare Bereich ergibt sich aus dem Rückwärtslauf der Transformation. Die Kachelplatzierung erfolgt im Pattern-Raum, aber das Einzige, was weiß, wie viel von der Seite tatsächlich berührt wird, ist die Geräte-Clipping-Box, die im Geräteraum liegt. HotPDF bildet BaseMatrix := CTM * PatternMatrix, invertiert sie und bildet die vier Ecken der GDI-Clipping-Box durch die Inverse zurück ab. Die achsenparallelen Grenzen dieser vier abgebildeten Ecken ergeben das Pattern-Raum-Rechteck, das überhaupt abgedeckt sein kann, und das Teilen dieses Rechtecks durch XStep und YStep relativ zur Pattern-BBox ergibt geschlossene Indexbereiche. Jede Zelle wird dann mit einer CTM von CTM * PatternMatrix * Translate(i * XStep, j * YStep) gerendert und ein zweites Mal auf ihr eigenes transformiertes BBox-Polygon geclipt. Dieses zweite Clipping ist relevant, wenn XStep kleiner als die Breite der Bounding-Box ist, wie sich überlappende Kachel-Designs ausdrücken; ohne es würden benachbarte Zellen sich außerhalb ihrer deklarierten Ausdehnung gegenseitig übermalen. Kommt das Per-Zell-Clipping als NULLREGION zurück, wird die Zelle übersprungen, ohne dass irgendetwas tokenisiert oder ausgeführt wird

// Map the device clip box back into pattern space through the inverse of
// CTM * PatternMatrix, then convert those bounds into tile index ranges.
BaseMatrix := HPDFMatMul(FGSStack.State.CTM, PatternMatrix);
if not HPDFMatInvert(BaseMatrix, InverseMatrix) then Exit;   // singular: refuse
if GetClipBox(FDC, ClipRect) = ERROR then Exit;

// MinX..MaxY are the axis-aligned bounds of the four mapped clip corners.
I0 := Floor((MinX - BBox[2]) / StepXAbs);
I1 := Ceil ((MaxX - BBox[0]) / StepXAbs);
J0 := Floor((MinY - BBox[3]) / StepYAbs);
J1 := Ceil ((MaxY - BBox[1]) / StepYAbs);

PlannedTiles := Int64(I1 - I0 + 1) * Int64(J1 - J0 + 1);
if (PlannedTiles <= 0) or (PlannedTiles > FPatternTilesRemaining) then Exit;
Dec(FPatternTilesRemaining, Integer(PlannedTiles));

Ungefärbte Pattern und die Farbe, die von außen kommt

Ein PaintType-2-Pattern trägt Form, aber keine Farbe, und die Farbe kommt mit dem Pattern-Namen an. §8.7.3.2 legt fest, dass ein ungefärbtes Pattern nur mit einem Pattern-Farbraum verwendet wird, der einen zugrunde liegenden Farbraum deklariert, sodass scn zuerst die Komponentenwerte und zuletzt den Pattern-Namen erhält. HotPDF löst diese Komponenten über den auf dem Pattern-Farbraum-Eintrag gespeicherten zugrunde liegenden Farbraum auf, was bedeutet, dass eine ungefärbte Schraffur genauso mit einer Separation-Farbe oder einer DeviceN-Kombination eingefärbt werden kann wie jede andere Füllung; die Mechanik dieser Auflösung wird in Rendering von Separation- und DeviceN-Sonderfarben behandelt. Innerhalb der Kachel gehen die beiden Paint-Typen deutlich auseinander. Bei PaintType 2 setzt der Renderer für die Dauer der Kachel ein Flag zur Unterdrückung von Farboperatoren, sodass jedes g, rg, k oder scn im Pattern-Content ignoriert wird und jede Markierung die von außen gelieferte Farbe erhält. Bei PaintType 1 gilt das Gegenteil: Füll- und Konturstatus werden auf die PDF-Vorgaben zurückgesetzt, DeviceGray-Schwarz mit Identitäts-Farbraum, und die Kachel färbt sich selbst. Dieses Zurücksetzen zu überspringen lässt die Farbe, die beim f-Operator zufällig aktuell war, in ein Pattern durchsickern, das eigentlich selbstbeschreibend sein sollte

Warum muss die Tiefe des Grafikstatus-Stacks nach jeder Kachel wiederhergestellt werden?

Weil ein Pattern-Content-Stream unausgeglichen sein darf und sich der Schaden über die Zellen hinweg summiert. Eine Kachel, deren Stream drei q-Operatoren und zwei Q-Operatoren enthält, hinterlässt den Stack einen Frame tiefer, als er begonnen hat. Stellt man zwischen den Zellen nur den aktuellen State-Record wieder her, wächst die Tiefe immer weiter, sodass Zelle Nummer zweihundert aus einem Stack-Frame heraus ausgeführt wird, der zu Zelle Nummer hundertneunundneunzig gehört, mit welcher CTM und welchem Clipping dieser Frame auch immer trug. HotPDF fertigt daher vor der Kachelschleife einen Snapshot von State-Record und Stack-Tiefe an und ruft am Anfang jeder Iteration RestoreSnapshot auf, was den Stack auf die gespeicherte Länge zurückschneidet und den gespeicherten Status in einem Schritt wiedereinsetzt. Das Resources-Dictionary der Seite und das Flag zur Unterdrückung von Farboperatoren werden an derselben Stelle wiederhergestellt, da eine Kachel eigene Ressourcen referenzieren kann und sie nicht an die nächste weitergeben darf. Der GDI-Clipping-Status erhält dieselbe Behandlung durch ein Paar SaveDC / RestoreDC um jede Zelle, sodass eine Kachel, die ihr eigenes W n-Clipping installiert, die für die nächste verfügbare Region nicht verkleinern kann

Budgets, Ablehnungen und was der Renderer nicht zeichnet

Tiling-Pattern sind die einfachste Stelle in einer PDF-Datei, um eine Denial-of-Service-Datei zu schreiben, deshalb sind die Grenzen harte Zahlen statt Heuristiken. Die Pattern-Verschachtelung ist auf Tiefe 4 begrenzt, dieselbe Absicherung, die auch für die Form-XObject-Rekursion verwendet wird und ein Pattern stoppt, das über sein eigenes Resource-Dictionary auf sich selbst verweist. Eine einzelne Pfadfüllung darf insgesamt höchstens 16.384 Kacheln ausführen, gezählt über verschachtelte Pattern hinweg und nur zurückgesetzt, wenn die Füllung des äußersten Patterns beginnt. Ein Kachelraster, dessen geplante Zellenzahl das übersteigt, was von diesem Budget noch übrig ist, wird von vornherein abgelehnt, bevor auch nur eine Zelle läuft

Entartete Geometrie wird abgelehnt statt angenähert. Eine fehlende oder flächenlose BBox, ein XStep oder YStep, dessen Betrag unter 1e-6 liegt, ein Produkt CTM * PatternMatrix ohne Inverse, abgebildete Clipping-Koordinaten jenseits von 1e9 oder ein Indexbetrag jenseits einer Million lassen die Pattern-Füllung allesamt ohne Zeichnung zurückkehren. Das Ergebnis ist ein ungemalter Bereich statt eines hängenden Render-Threads, was in einem Batch-Konverter genau der gewünschte Kompromiss ist. Performance entsteht aus einer Entscheidung: Der Pattern-Stream wird pro Füllung einmal mit HPDFTokenizeContentStream tokenisiert, und das Token-Array wird über jede sichtbare Zelle hinweg wiederverwendet, sodass die Kachelanzahl die Ausführungskosten vervielfacht, aber niemals die Lexing-Kosten

Eine gemusterte Seite von Delphi aus rendern

Nichts an der Pattern-Unterstützung ändert etwas am aufrufenden Code. Das Dokument laden, eine Seite anfordern, und die Kachelarbeit findet innerhalb des Content-Stream-Interpreters statt, der bereits das Seite-zu-Bitmap-Rendering antreibt. Derselbe Interpreter versorgt Bitmap-, Metafile- und Drucker-Gerätekontexte, sodass eine schraffierte Zeichnung, die in einer Vorschau-Miniaturansicht korrekt aussieht, mit derselben Kachelgeometrie gedruckt wird. PatternType-2-Shading-Pattern nehmen einen anderen Zweig, der sich seinen Auswertungspfad mit dem bloßen sh-Operator teilt, ausführlich beschrieben unter Rendering von axialen und radialen Shadings

var
  Pdf: THotPDF;
  Bmp: TBitmap;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('assembly-drawing.pdf') > 0 then
    begin
      // Section hatching that previously flattened to a solid block now
      // replays the tile content once per visible cell.
      Bmp := Pdf.RenderLoadedPageToBitmap(0, 200);
      if Assigned(Bmp) then
      try
        Bmp.SaveToFile('sheet1.bmp');
      finally
        Bmp.Free;
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Sieht ein gemusterter Bereich noch immer falsch aus, prüft man die drei Fehlerklassen der Reihe nach. Ein völlig leerer Bereich bedeutet meist eine Ablehnung: XStep, YStep und BBox auf entartete Werte prüfen, oder die Kachelanzahl, die das Raster benötigen würde, gegen die Obergrenze von 16.384 zählen. Ein in einer einzigen flachen Farbe gemalter Bereich bedeutet, dass der Pattern-Name nie den Paint-Operator erreicht hat, was auf die Reihenfolge von cs und scn im Stream hindeutet. Ein Pattern, das dort auftaucht, wo es nicht hingehört, bedeutet Statuswiederherstellung, und der richtige Ort zum Nachsehen ist die q / Q-Behandlung um das Formular oder den Pfad, der es geerbt hat

Tiling-Pattern gehören zu den PDF-Funktionen, die unsichtbar bleiben, bis genau die Datei, die sie braucht, im eigenen Posteingang landet, und dann sind sie die ganze Aufgabe. Wer Zeichnungs-Viewer, Konverter für technische Dokumente oder Report-Renderer auf Delphi oder C++Builder baut, findet die vollständige Komponente und ihre Rendering-API auf der Seite der HotPDF-Delphi-PDF-Komponente