PDFlibPas, die native VCL-PDF-Komponentenbibliothek für Delphi und C++Builder, spielt den Content-Stream einer Seite über ihre Klasse TPDFContentStateTracker ab, ohne überhaupt eine Render-Canvas anzufassen. Den Tracker mit jeweils einem geparsten Operator zu füttern, hält einen laufenden Grafikzustands-Datensatz — aktuelle Transformationsmatrix, Textmatrix, Clip-Grenzen und den q/Q-Speicher-Stack — verfügbar für einen Schnappschuss vor oder nach der Ausführung jedes Operators
Fragen Sie, wo ein Textlauf tatsächlich auf der gedruckten Seite landet, und die rohen Zahlen des Content-Streams allein werden Sie jedes Mal in die Irre führen. TPDFContentProgram.GetTextRuns meldet bereits den Ankerpunkt jeder Text-Ausgabe-Anweisung über die Felder OriginX und OriginY auf TPDFTextRun, und die Feld-Kommentare sind ausdrücklich: dieser Punkt sitzt im Textraum, bereits durch Tm, Td, TD und T* gefaltet. Was noch fehlt, und was diese Kommentare sagen, muss ein Aufrufer selbst liefern, ist die zu genau dieser Anweisung aktive CTM — das Produkt jedes bisher verketteten cm, verschachtelt innerhalb wie vieler q/Q-Paare auch immer an diesem Punkt im Stream offen sind
Warum einen Content-Stream abspielen statt ihn zu rendern?
PDFlibPas hält zwei separate Vorstellungen von Grafikzustand für zwei separate Aufgaben, und die Trennung ist absichtlich. Der interne Zustandsdatensatz des Renderers trägt ein lebendes Geräte-Canvas-Handle, ein Clip-Region-Handle und Font-Rasterisierungs-Caches — echte Ressourcen, gebunden an welche Oberfläche auch immer gerade gemalt wird, und bedeutungslos, sobald diese Oberfläche verschwindet. TPDFContentGraphicsState trägt nichts davon: Es ist ein schlichter Datensatz, begrenzt auf die Werte, die ISO 32000-1 §8.4 als allein aus Content-Stream-Operatoren erreichbar definiert — die CTM, Linienstil, Farbe, Textzustand und die abgeleiteten Clip- und Pfadgrenzen. Weil der Datensatz keine Canvas-Referenz und kein offenes Datei-Handle trägt, kann ein Aufrufer einen Content-Stream parsen, ihn mit TPDFContentStateTracker durchlaufen und die entstehenden Schnappschüsse weiterverwenden, lange nachdem verschwunden ist, was auch immer die Bytes erzeugt hat
Wie TPDFContentStateTracker die CTM aufbaut
TPDFContentStateTracker.Apply verkettet die sechs Operanden eines cm-Operators in die CTM des Trackers, unter Verwendung derselben Vormultiplikation, die PDF selbst festlegt: Die neue Matrix M2 kombiniert sich mit der aktuellen CTM als M2 × CTM, in der Zeilenvektor-Konvention, in der ein Punkt sich als P′ = P × M transformiert (ISO 32000-1 §8.4). Der leicht falsch zu machende Teil sitzt im Translationsterm, nicht im linearen Teil: Die eigene Translation von M2 muss durch die Rotations-und-Skalierungs-Komponente der aktuellen CTM laufen, bevor die Translation der aktuellen CTM oben draufaddiert wird. Überspringt man diesen Schritt und hartcodiert stattdessen eine naive komponentenweise Kombination, sieht das erste isolierte getestete cm korrekt aus, während jede Koordinate stromabwärts eines zweiten oder dritten verschachtelten cm still driftet, genau die Art von Bug, die die Code-Review übersteht, weil der Unit-Test, der ihn fangen würde, mindestens zwei verkettete Transformationen braucht, um zu scheitern
var
Prog: TPDFContentProgram;
Runs: TPDFTextRunArray;
States: TPDFContentGraphicsStateArray;
DeviceX, DeviceY: Double;
I: Integer;
begin
Prog := TPDFContentProgram.Create;
try
Prog.Parse(ContentBytes);
Runs := Prog.GetTextRuns;
// One before-instruction snapshot per operator, computed in a single pass
States := Prog.TraceGraphicsStates(nil, False);
for I := 0 to High(Runs) do
begin
// OriginX/OriginY already fold in Tm/Td/TD/T*; only the CTM active
// at this instruction is still missing (ISO 32000-1 8.4)
with States[Runs[I].InstructionIndex].CTM do
begin
DeviceX := Runs[I].OriginX * M11 + Runs[I].OriginY * M21 + DX;
DeviceY := Runs[I].OriginX * M12 + Runs[I].OriginY * M22 + DY;
end;
LogTextOrigin(Runs[I].Text, DeviceX, DeviceY); // caller-supplied handler
end;
finally
Prog.Free;
end;
end;
Die obige Schleife beantwortet den Schmerzpunkt aus der Einleitung: TPDFContentProgram.GetTextRuns gibt OriginX und OriginY bereits durch Tm, Td, TD und T* gefaltet zurück, und TraceGraphicsStates(nil, False) liefert das eine verbleibende Stück, die Vor-Anweisung-CTM am genauen Index, an dem jeder Lauf erfasst wurde, in einem einzigen linearen Durchgang über das gesamte Programm. nil zu übergeben lässt die Methode einen privaten Tracker für den Aufruf besitzen und intern freigeben, die richtige Wahl für einen einmaligen Scan; stattdessen eine bestehende TPDFContentStateTracker-Instanz zu übergeben ist das, was den Zustand über eine aus mehr als einem Content-Stream zusammengesetzte Seite hinweg kontinuierlich hält, da ISO 32000-1 das /Contents-Array einer Seite als einen logischen Stream behandelt und der q/Q-Stack übereinstimmen muss
Die Textmatrix überlebt Q; der Grafikzustand nicht
ISO 32000-1 §9.4.2 definiert Td, TD, Tm und T* als die Operatoren, die die Textmatrix und Textzeilenmatrix innerhalb eines BT/ET-Blocks aufbauen, und PDFlibPas hält diese Unterscheidung scharf: Td und TD verketten eine reine Translation auf die Textzeilenmatrix, T* tut dasselbe mit dem Negativ des aktuellen Zeilenabstands (Leading), und nur Tm ersetzt beide Matrizen rundweg durch die sechs ihm übergebenen Zahlen. BT setzt beide Matrizen auf die Identität zurück, genau einmal, am Anfang des Textobjekts — aber q und Q berühren sie überhaupt nicht. TPDFContentStateTracker.Apply behandelt coRestoreState genau aus diesem Grund als Sonderfall: Bevor es den gespeicherten Zustand vom Stack nimmt, erfasst es die aktuelle Textmatrix, Textzeilenmatrix und das BT/ET-Flag, und wendet sie erneut über das an, was der entnommene Zustand zufällig enthielt, weil ein um einen Textlauf gewickeltes q/Q-Paar die Textposition nicht zurückbewegen soll
var
Tracker: TPDFContentStateTracker;
Prog: TPDFContentProgram;
I: Integer;
begin
Prog := TPDFContentProgram.Create;
Tracker := TPDFContentStateTracker.Create;
try
Prog.Parse('BT 100 700 Td q 2 0 0 2 0 0 cm (A) Tj Q (B) Tj ET');
for I := 0 to Prog.Count - 1 do
begin
Tracker.Apply(Prog[I]);
if Prog[I].Op in [coShowText, coRestoreState] then
LogState(Prog[I].OpName, Tracker.Snapshot); // caller-supplied handler
end;
finally
Tracker.Free;
Prog.Free;
end;
end;
Lassen Sie diese Sequenz laufen, ist die beim zweiten Tj gemeldete CTM wieder auf dem Identitäts-Maßstab, den sie vor q hatte — das 2 0 0 2 0 0 cm innerhalb des Speicher/Wiederherstellen-Paars ist weg, wie q/Q es verlangt. TextMatrix.DX bei derselben Anweisung ist jedoch weiterhin 100: Das Td, das es setzte, lief vor dem q, sodass es kein Grafikzustand ist, den das Q je hätte berühren dürfen, und ein Werkzeug, das das Gegenteil annähme, würde melden, der zweite Glyphenlauf beginne an der falschen horizontalen Position auf der Seite
Was passiert, wenn ein Clipping-Pfad-Operator läuft?
Ein W- oder W*-Operator verkleinert den Clip nicht sofort; er protokolliert nur, welche Füllregel zu verwenden ist, und die eigentliche Schnittmenge wartet auf welchen Pfad-Male-Operator auch immer darauf folgt, einschließlich des No-op-Malers n, den PDF-Autoren routinemäßig genau dazu verwenden, um zu clippen, ohne irgendetwas zu zeichnen. TPDFContentStateTracker spiegelt dieses zweistufige Timing genau: coClip und coClipEvenOdd setzen nur ein anstehendes Clip-Regel-Flag, und EndCurrentPath — von jedem Pfad-Male-Operator aufgerufen — ist das, was die Grenzen des anstehenden Pfads tatsächlich in ClipMinX, ClipMinY, ClipMaxX und ClipMaxY schneidet. Diese Staffelung richtig hinzubekommen zählt für den Vor/Nach-Schnappschuss-Vertrag selbst: Ein genau bei der W-Anweisung genommener Vor-Schnappschuss muss weiterhin den alten, weiteren Clip zeigen, weil der Clip an diesem Punkt im Stream noch nicht wirksam wurde, und die zwei Schritte zu einem zusammenzufalten würde still jeden Aufrufer brechen, der sich darauf verlässt, dass Vor-Zustand bedeutet, was er sagt
ClipBoundsExact sagt einem Aufrufer, welche von zwei Situationen er vor sich hat, und ist nur je True für ein einzelnes achsenausgerichtetes Rechteck, gebaut von re auf einem ansonsten leeren Pfad — die eine Form, die PDFlibPas exakt als vier Zahlen darstellen kann. Alles andere — ein rotiertes Rechteck, ein gekrümmter Umriss, ein zusammengesetzter Pfad mit mehreren Teilpfaden, oder ein aus einem Text-Rendering-Modus gebauter Clip — erzeugt weiterhin ClipMinX bis ClipMaxY, aber mit auf False gesetztem ClipBoundsExact, ein ehrliches Signal, dass die vier Zahlen eine sichere äußere Grenze sind, nicht die wahre Clip-Form; Aufrufer, die nur diese Grenze brauchen, etwa um einen rechteckigen Teilbereich zu isolieren, bevor die in dem Rendern von PDF-Seiten zu 1-Bit-Monochrom beschriebene GDI-Halbton-Herabkonvertierung läuft, können sie direkt lesen, statt sie aus der Seitengeometrie neu abzuleiten
Bézierkurven: eine exakte Grenze oder eine sichere
Der billigste Weg, ein kubisches Bézier-Segment zu begrenzen, ist die konvexe Hülle seiner vier Kontrollpunkte zu nehmen, und das ist immer sicher, weil die Kurve sie nie verlässt — aber eine flache, breite Kurve kann eine Bounding-Box melden, die weit größer ist als die Fläche, die die Kurve tatsächlich einnimmt, was clip-basierte Filterung genau dann schwächt, wenn es am meisten zählt, bei großen dekorativen Pfaden. PDFlibPas löst stattdessen das engere Problem: Für jede Achse löst es die Ableitung der kubischen Kurve nach Nullstellen innerhalb des offenen Intervalls (0, 1) und wertet die Kurve an allen gefundenen Nullstellen aus, zusammen mit beiden Endpunkten, was der Standard-Weg in geschlossener Form ist, um die wahre achsenausgerichtete Ausdehnung einer Kurve zu bekommen, statt einer Überschätzung. Präzision pro Kurve überträgt sich jedoch nicht auf den Clip selbst: Sobald ein gekrümmter Umriss zu einem Clipping-Pfad wird, fällt ClipBoundsExact für ihn weiterhin auf False, weil eine Bounding-Box, wie eng auch immer, immer noch nicht dieselbe Form ist wie die Kurve, die sie begrenzt, und der Zustands-Tracker würde das lieber so sagen, als einen Aufrufer ein Rechteck annehmen zu lassen, wo tatsächlich eine Kurve ist
Zustand vor und nach jedem Operator lesen
Ob ein Aufrufer den Vor- oder Nach-Zustand will, hängt vollständig davon ab, was der Operator tut: Eine Zeichen- oder Hit-Testing-Frage über einen Pfad oder Textlauf will den Zustand, wie er unmittelbar vor der Ausführung dieses Operators stand, da das ist, was tatsächlich bestimmte, wie der Operator malte, während eine Diagnosefrage über einen zustandssetzenden Operator wie gs meist sehen will, was er gerade geändert hat. TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction) legt genau diese Wahl als einzelnen Boolean frei und berechnet einen TPDFContentGraphicsState pro Anweisung in einem einzigen linearen Durchgang über das gesamte Programm, unabhängig davon, welcher Moment angefordert wird. GetGraphicsState(InstructionIndex, AfterInstruction, State) bietet dieselbe Vor/Nach-Wahl für eine einzelne Anweisung statt das gesamte Programm, spielt aber bei jedem Aufruf ab Anweisung null ab, um dorthin zu gelangen, sodass das Scannen vieler Indizes durch Aufruf in einer Schleife O(n²) gegenüber einem einzigen O(n)-Aufruf von TraceGraphicsStates über dasselbe Programm kostet
var
Before, After: TPDFContentGraphicsState;
begin
// Same instruction index, two different instants: before vs. after it runs
Prog.GetGraphicsState(CmIndex, False, Before);
Prog.GetGraphicsState(CmIndex, True, After);
// Before.CTM reflects every earlier cm; After.CTM already folds in
// this instruction's own concatenation as well
end;
Mit fehlerhaften Content-Streams leben
Zwei Arten fehlerhafter Eingabe sind bei echten PDF-Erzeugern häufig genug, dass TPDFContentStateTracker sie tolerieren muss, statt daran zu scheitern. Die erste ist ein Pfad, der über eine q/Q-Grenze hinweg reicht: Der aktuelle Pfad, aktuelle Punkt und die Teilpfad-Anzahl sind keine Grafikzustand-Parameter — ISO 32000-1 §8.4 behandelt, was q und Q speichern und wiederherstellen, und der gerade aufgebaute aktuelle Pfad ist nicht darunter —, sodass TPDFContentStateTracker diese Daten vollständig außerhalb des gespeicherten Zustands verfolgt, und ein vor einem q begonnener Teilpfad ist unmittelbar nach dem passenden Q noch da, ungemalt. Der zweite ist ein bloßes Q ohne passendes q davor irgendwo im Stream, nicht selten in Ausgaben von Generatoren, die Content-Stream-Fragmente durch Verkettung zusammenbauen und die Buchhaltung falsch machen. TPDFContentStateTracker.RestoreUnderflowCount zählt jedes dieser Ereignisse, statt eine Exception zu werfen oder den Zustand zu beschädigen: Ein unpassendes Q lässt den aktuellen Grafikzustand einfach genau so, wie er war, als wäre diese Anweisung ein No-op gewesen, sodass der Rest des Streams weiter auf einem vernünftigen Zustand abspielt und ein Aufrufer aus der Zählung im Nachhinein immer noch entscheiden kann, ob die Eingabe es wert ist, an ihren Erzeuger zurückgemeldet zu werden
CTM-Komposition, die Unabhängigkeit der Textmatrix von q/Q und die gestaffelte Realisierung eines Clipping-Pfads hängen nicht davon ab, wie oder ob der Content-Stream je gemalt wird, was genau der Punkt ist: Derselbe TPDFContentStateTracker-Schnappschuss ist korrekt, egal ob die Seite überhaupt nie gerendert wird oder gerade an welches Backend auch immer übergeben werden soll, das PDFlibPas für diese Datei wählt, einschließlich des in dem Leitfaden zu Multi-Engine-PDF-Rendering in PDFlibPas behandelten Laufzeit-Engine-Wechsels. Inhaltsanalyse, Koordinaten-Abbildung und Redaktions-Tooling können alle vollständig auf der Ausgabe des Trackers laufen, lange bevor oder vollkommen ohne je einen Renderer ins Spiel zu bringen
Content-Stream-Wiedergabe über TPDFContentStateTracker ist Teil des in PDFlibPas, der nativen VCL-PDF-Komponentenbibliothek für Delphi und C++Builder, eingebauten strukturierten Content-Bearbeitungs-Frameworks