PDFlibPas, the native VCL PDF component library for Delphi and C++Builder, replays a page's content stream through its TPDFContentStateTracker class without touching a rendering canvas at all. Feeding the tracker one parsed operator at a time keeps a running graphics-state record — current transformation matrix, text matrix, clip bounds, and the q/Q save stack — available for snapshot before or after every operator executes
Ask where a run of text actually lands on the printed page, and the content stream's raw numbers alone will mislead you every time. TPDFContentProgram.GetTextRuns already reports each text-showing instruction's anchor point through the OriginX and OriginY fields on TPDFTextRun, and the field comments are explicit that this point sits in text space, already folded through Tm, Td, TD, and T*. What is still missing, and what those comments say a caller must supply, is the CTM active at that exact instruction — the product of every cm concatenated so far, nested inside however many q/Q pairs happen to be open at that point in the stream
Why replay a content stream instead of rendering it?
PDFlibPas keeps two separate notions of graphics state for two separate jobs, and the split is deliberate. The renderer's internal state record carries a live device-canvas handle, a clipping-region handle, and font-rasterisation caches — real resources tied to whatever surface is currently being painted, and meaningless once that surface goes away. TPDFContentGraphicsState carries none of that: it is a plain record limited to the values ISO 32000-1 §8.4 defines as reachable from content-stream operators alone — the CTM, line style, colour, text state, and the derived clip and path bounds. Because the record holds no canvas reference and no open file handle, a caller can parse a content stream, walk it with TPDFContentStateTracker, and keep using the resulting snapshots long after whatever produced the bytes has gone away
How TPDFContentStateTracker builds the CTM
TPDFContentStateTracker.Apply concatenates a cm operator's six operands into the tracker's CTM using the same premultiply PDF itself specifies: the new matrix M2 combines with the current CTM as M2 × CTM, in the row-vector convention where a point transforms as P′ = P × M (ISO 32000-1 §8.4). The part that is easy to get wrong sits in the translation term, not the linear part: M2's own translation has to pass through the current CTM's rotation-and-scale component before the current CTM's translation is added on top. Skip that step and hard-code a naive component-wise combination instead, and the first isolated cm you test will look correct while every coordinate downstream of a second or third nested cm quietly drifts, which is exactly the kind of bug that survives code review because the unit test that would catch it needs at least two chained transforms to fail
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;
The loop above answers the pain point from the opening: TPDFContentProgram.GetTextRuns hands back OriginX and OriginY already folded through Tm, Td, TD, and T*, and TraceGraphicsStates(nil, False) supplies the one remaining piece, the before-instruction CTM at the exact index each run was captured at, in a single linear pass over the whole program. Passing nil lets the method own a private tracker for the call and free it internally, which is the right choice for a one-off scan; passing an existing TPDFContentStateTracker instead is what keeps state continuous across a page assembled from more than one content stream, since ISO 32000-1 treats a page's /Contents array as one logical stream and the q/Q stack has to agree
The text matrix survives Q; the graphics state does not
ISO 32000-1 §9.4.2 defines Td, TD, Tm, and T* as the operators that build the text matrix and text line matrix inside a BT/ET block, and PDFlibPas keeps that distinction sharp: Td and TD concatenate a pure translation onto the text line matrix, T* does the same using the negative of the current leading, and only Tm replaces both matrices outright with the six numbers it is given. BT resets both matrices to the identity, exactly once, at the start of the text object — but q and Q do not touch them at all. TPDFContentStateTracker.Apply special-cases coRestoreState for precisely this reason: before it pops the saved state off the stack, it captures the current text matrix, text line matrix, and BT/ET flag, and reapplies them over whatever the popped state happened to hold, because a q/Q pair wrapped around a text run is not supposed to move the text position back
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;
Run that sequence and the CTM reported at the second Tj is back to the identity scale it had before q — the 2 0 0 2 0 0 cm inside the save/restore pair is gone, as q/Q requires. TextMatrix.DX at that same instruction, though, is still 100: the Td that set it ran before the q, so it is not graphics state the Q was ever entitled to touch, and a tool that assumed otherwise would report the second glyph run starting from the wrong horizontal position on the page
What happens when a clipping path operator runs?
A W or W* operator does not shrink the clip immediately; it only records which fill rule to use, and the actual intersection waits for whichever path-painting operator follows it, including the no-op painter n that PDF authors routinely use exactly to clip without drawing anything. TPDFContentStateTracker mirrors that two-step timing precisely: coClip and coClipEvenOdd only set a pending clip-rule flag, and EndCurrentPath — invoked by every path-painting operator — is what actually intersects the pending path's bounds into ClipMinX, ClipMinY, ClipMaxX, and ClipMaxY. Getting this staging right matters for the before/after snapshot contract itself: a before-snapshot taken exactly at the W instruction still has to show the old, wider clip, because the clip has not taken effect yet at that point in the stream, and collapsing the two steps into one would quietly break every caller relying on before-state to mean what it says
ClipBoundsExact tells a caller which of two situations it is looking at, and it is only ever True for a single axis-aligned rectangle built by re on an otherwise empty path — the one shape PDFlibPas can represent exactly as four numbers. Everything else — a rotated rectangle, a curved outline, a compound path with several subpaths, or a clip built from a text-rendering mode — still produces ClipMinX through ClipMaxY, but with ClipBoundsExact cleared to False, an honest signal that the four numbers are a safe outer bound and not the true clip shape; callers that only need that bound, such as isolating a rectangular sub-region before the GDI halftone down-conversion described in rendering PDF pages to 1-bit monochrome, can read it directly instead of re-deriving it from the page geometry
Bézier curves: an exact bound or a safe one
The cheapest way to bound a cubic Bézier segment is to take the convex hull of its four control points, and that is always safe because the curve never leaves it — but a shallow, wide curve can report a bounding box far larger than the curve actually occupies, which weakens clip-based filtering exactly when it matters most, on large decorative paths. PDFlibPas solves the tighter problem instead: for each axis, it solves the cubic curve's derivative for roots inside the open interval (0, 1) and evaluates the curve at any roots it finds, together with both endpoints, which is the standard closed-form way to get a curve's true axis-aligned extent rather than an overestimate. Per-curve precision does not carry over to the clip itself, though: once a curved outline becomes a clipping path, ClipBoundsExact still drops to False for it, because a bounding box, however tight, is still not the same shape as the curve it bounds, and the state tracker would rather say so than let a caller assume a rectangle where a curve actually is
Reading state before and after each operator
Whether a caller wants the before or after state depends entirely on what the operator does: a drawing or hit-testing question about a path or text run wants the state as it stood the instant before that operator ran, since that is what actually determined how the operator painted, while a diagnostic question about a state-setting operator like gs usually wants to see what it just changed. TPDFContentProgram.TraceGraphicsStates(Tracker, AfterInstruction) exposes exactly that choice as a single Boolean, computing one TPDFContentGraphicsState per instruction in one linear pass over the whole program regardless of which instant is requested. GetGraphicsState(InstructionIndex, AfterInstruction, State) offers the same before/after choice for a single instruction instead of the whole program, but it replays from instruction zero on every call to get there, so scanning many indices by calling it in a loop costs O(n²) against a single O(n) call to TraceGraphicsStates over the same program
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;
Living with malformed content streams
Two kinds of malformed input are common enough in real PDF producers that TPDFContentStateTracker has to tolerate them rather than fail on them. The first is a path that spans a q/Q boundary: the current path, current point, and subpath count are not graphics-state parameters — ISO 32000-1 §8.4 covers what q and Q save and restore, and the current path being built is not among it — so TPDFContentStateTracker tracks that data outside the saved state entirely, and a subpath begun before a q is still there, unpainted, immediately after the matching Q. The second is a bare Q with no matching q anywhere before it in the stream, not rare in output from generators that assemble content-stream fragments by concatenation and get the bookkeeping wrong. TPDFContentStateTracker.RestoreUnderflowCount counts every one of those events instead of raising an exception or corrupting state: an unmatched Q just leaves the current graphics state exactly as it was, as if that instruction had been a no-op, so the rest of the stream keeps replaying on a sane state and a caller can still decide afterward, from the count, whether the input is worth flagging back to whoever produced it
CTM composition, the text matrix's independence from q/Q, and the staged realisation of a clipping path do not depend on how or whether the content stream ever gets painted, which is exactly the point: the same TPDFContentStateTracker snapshot is correct whether the page is never rendered at all or is about to be handed to whichever back end PDFlibPas selects for that file, including the runtime engine switching covered in the guide to multi-engine PDF rendering in PDFlibPas. Content analysis, coordinate mapping, and redaction tooling can all run entirely on the tracker's output, long before or completely without ever asking a renderer to get involved
Content-stream replay through TPDFContentStateTracker is part of the structured content editing framework built into PDFlibPas, the native VCL PDF component library for Delphi and C++Builder