Technical Article

PDF Renderer Draws Nothing: Four Silent Delphi Bugs

A PDF renderer that draws nothing usually has no bug in its drawing code at all. In the HotPDF Component for Delphi and C++Builder, four separate defects made pages render blank while every log line stayed clean: name operands carrying a leading slash, a reversed cm concatenation, and a token index that read zero. None of them threw. None of them logged. The content stream tokenised correctly, the operator dispatcher recognised every operator, the image XObject was decoded into a valid bitmap, and then the page came out empty. That combination — a pipeline that reports success at every stage and produces nothing visible — is the signature of a lookup or an index that silently misses rather than fails. This is a post-mortem of one such family, and of the test discipline that let it survive for 38 releases

Why does a PDF renderer draw nothing at all?

Because a failed resource lookup in a PDF renderer is indistinguishable from an empty page. Content-stream name operands and resource-dictionary keys are two different string spaces, and HotPDF was comparing across them without normalising. The tokeniser reads /Im0 and keeps the solidus, because that is what the token is; the loaded /Resources /XObject dictionary stores the key as Im0, because the parser strips the delimiter when it builds dictionary keys. Every FindValue against an operand name therefore returned -1. The blast radius was wider than images. ISO 32000-1 §8.9 covers Do, §8.4 covers gs and its /ExtGState lookup, §8.6 covers cs and CS, and §8.7.4.3 covers sh. All five operators keyed their resource sub-dictionary by the raw operand, so all five missed. Named colour spaces fell back to DeviceGray, which turns 1 scn into white ink on a white page. Image XObjects were never painted at all — the bitmap image path had, in practice, never worked since the day it landed. The fix is a unit-level helper applied at every operand-keyed lookup, which is the only way to keep the convention from drifting again

// Page content stream, the ordinary image-placement idiom:
//   q
//   /GS0 gs
//   200 0 0 120 60 400 cm
//   /Im0 Do
//   Q
// The operand token is '/Im0'. The resource dictionary key is 'Im0'.

function HPDFStripNameSlash(const N: AnsiString): AnsiString;
begin
  Result := N;
  if (Result <> '') and (Result[1] = '/') then
    Delete(Result, 1, 1);
end;

// Every resource lookup keyed by an operand name goes through the helper.
Name := HPDFStripNameSlash(Name);
XObjIdx := FPageResources.FindValue('XObject');
if XObjIdx < 0 then
  Exit;
// The /XObject sub-dictionary may itself be an indirect reference.
XObjDict := FAccess.ResolveDictionary(FAccess.Context,
  FPageResources.GetIndexedItem(XObjIdx));
if XObjDict = nil then
  Exit;

A second, related miss sat one layer down. The renderer had typed resolvers for streams and dictionaries only, so an indirect reference pointing at a top-level array object — the common /CS0 5 0 R with [/Separation ...] at the other end — resolved to nil through both and fell back to the unresolved link. Adding a generic object resolver fixed named colour spaces and function arrays in one move. If you are wiring up shading dictionaries, the same resolution discipline applies to the axial and radial shading path, where the /Function entry is very often indirect

The cm operator and a concatenation written backwards

The second defect placed images roughly a hundred thousand pixels off the page, which looks exactly like not drawing them. ISO 32000-1 §8.3.4 defines PDF transformations with row vectors, and the cm operator concatenates its operand matrix M onto the current transformation matrix as M × CTM — M takes effect first, the existing CTM afterwards. HotPDF composes matrices through HPDFMatMul(A, B), which applies B before A. The correct call therefore passes the old CTM as A. The shipped code passed the operand matrix as A, producing CTM × M

Reversed order is harmless for a single cm and catastrophic for the standard two-step idiom. Place an image with 1 0 0 1 x y cm followed by w 0 0 h 0 0 cm and the correct cascade scales the unit square by (w, h) and then translates it by (x, y). Under the reversed cascade the translation goes in first and the scale multiplies it, so an image nominally at (60, 400) scaled to 200 by 120 lands at (12000, 48000). The clip test at the top of the blit rejects it, the blit is skipped, and nothing anywhere reports a problem

// HPDFMatMul(A, B) applies B first, then A.
// ISO 32000-1 cm semantics: new CTM = M x CTM, so M must be B.

// Wrong, and shipped for 38 versions:
GS.CTM := HPDFMatMul(HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                    NumAt(3), NumAt(2), NumAt(1)), GS.CTM);

// Correct:
GS.CTM := HPDFMatMul(GS.CTM, HPDFMatFromOps(NumAt(6), NumAt(5), NumAt(4),
                                            NumAt(3), NumAt(2), NumAt(1)));

What makes this one instructive is that the same source file already contained the correct order. The /Matrix entry of a Form XObject had the same reversed composition, but the Type 3 glyph path and the embedded glyph outline path both got it right from the start, because glyph placement collapses visibly to the origin when you invert it and someone had already been forced to fix it. Two conventions coexisted in one unit for three dozen releases, each correct in its own function, and no reviewer noticed because neither call site looked wrong in isolation

What happens when a token index is off by one?

You get twelve operators that are syntactically handled and semantically dead. The operand accessor in the renderer is NumAt(Back), which reads Tokens[OpIndex - Back], and OpIndex is the index of the operator token itself. A single-operand operator therefore finds its number at back 1. Twelve of them were written as NumAt(0), which reads the operator token, fails the ctOperandNumber kind check, and returns the zero default. The list is Tc, Tw, Tz, TL, Ts and Tr from the text state operators of ISO 32000-1 §9.3, plus w, J, j, M, ri and i from the graphics state operators of §8.4.3. Character and word spacing became no-ops, horizontal scaling never applied, leading stayed at zero so T* never advanced a line, text rise did nothing, render mode was always fill, and every stroke in every document came out as a 1-pixel hairline regardless of the declared line width. Multi-operand operators such as m, rg and Tm used NumAt(1..6) and were all correct, so a reviewer scanning the function saw a wall of plausible index arithmetic with twelve wrong entries embedded in it

function NumAt(Back: Integer): Double;
begin
  Result := 0;
  if (OpIndex - Back >= 0)
    and (Tokens[OpIndex - Back].Kind = ctOperandNumber) then
    Result := Tokens[OpIndex - Back].NumValue;
end;

// OpIndex addresses the operator token, so a lone operand sits at back 1.
else if Op = 'Tc' then GS.Text.CharSpace := NumAt(1)   // previously NumAt(0)
else if Op = 'TL' then GS.Text.Leading   := NumAt(1)   // previously NumAt(0)
else if Op = 'Tr' then GS.Text.RenderMode := Round(NumAt(1))
else if Op = 'w'  then GS.LineWidth      := NumAt(1)   // previously NumAt(0)

Why did the test suite stay green for 38 versions?

Because the assertions were too weak to distinguish a rendered page from a partially rendered one. The rendering smokes asserted things like the output bitmap is not entirely black, or the page is not blank, or the image digest is non-zero. Every one of those holds when text renders and images do not. Text drew fine, so the frame buffer was never uniform, the digest was never zero, and the suite reported success while the entire image pipeline was dead code in practice. Weak assertions are seductive for graphics precisely because strong ones look brittle. Nobody wants a test that breaks when an anti-aliasing edge shifts by one pixel, so the natural retreat is to assert something that no reasonable change could violate — and that retreat lands you on predicates no unreasonable change can violate either. A separation colour-space test asserted the output was distinguishable from black; grey on white passed it, and so did white on white. The test was not measuring whether the right colour was painted. It was measuring whether anything at all had happened on the canvas

How do you write a rendering assertion that actually fails?

Count pixels of the expected colour, in the expected quantity, and let position and size fall out of the count. The replacement discipline is a hand-built minimal PDF, one visual fact per file, and an assertion on how many pixels land within a tolerance of a specific RGB triple. A 200 by 120 image of pure red placed at a known offset must produce roughly 24000 red pixels. If the resource lookup misses, the count is 0. If the cm cascade is reversed, the count is 0. If the image renders in the wrong colour space, the count is 0. One number catches all three, and the tolerance band absorbs the anti-aliasing noise that made people flinch from exact comparison in the first place

function CountPixelsNear(Bmp: TBitmap; R, G, B, Tol: Integer): Integer;
var
  X, Y: Integer;
  C: TColor;
begin
  Result := 0;
  for Y := 0 to Bmp.Height - 1 do
    for X := 0 to Bmp.Width - 1 do
    begin
      C := Bmp.Canvas.Pixels[X, Y];
      if (Abs(GetRValue(C) - R) <= Tol)
        and (Abs(GetGValue(C) - G) <= Tol)
        and (Abs(GetBValue(C) - B) <= Tol) then
        Inc(Result);
    end;
end;

// A 200x120 red image placed at 60,400 must paint about 24000 red pixels.
Check(CountPixelsNear(Bmp, 255, 0, 0, 12) > 20000,
  'image XObject was never drawn');

Four smokes were rewritten this way — a Type 4 tint transform, an image Do placement, an optional-content visibility case and a Tr stroke mode — and between them they exposed the whole family. That is the real lesson, and it generalises past this codebase: in a rendering pipeline, the assertion has to name the colour. Anything softer is a check that the renderer ran, not a check that it drew. If you are building your own page-to-bitmap harness, the page rasterisation walkthrough is the natural place to bolt a pixel-count helper onto your first regression

Honest boundaries

Two limits are worth stating plainly. Text clipping render modes 4 through 7 are drawn as their base fill or stroke mode, because the renderer does not model accumulated clip paths from glyph outlines; documents that rely on text-shaped clipping will render the text rather than the clipped artwork underneath. And the pixel-count discipline described here is a smoke-test technique, not a conformance suite — it proves that a specific visual fact reached the frame buffer, which is a much lower bar than proving output matches a reference rasteriser. It is, however, exactly the bar these four bugs failed to clear for three years of releases

The renderer discussed here ships as part of the standard HotPDF Component for Delphi and C++Builder; the product page carries the full page-rendering API reference, including the bitmap cache and background prefetch entry points