The PDF gotcha that cost an afternoon

A hairline rule in a table looked wrong by 10 to 25 points, only on rows after a letterspaced date chip. The bug wasn't in the alignment math; it was in a PDF operator that survives past where you'd expect it to end. Drag the slider to leak it yourself.

A document generator built on reportlab started drawing right-aligned numbers past the end of their column, by exactly the amount you’d expect from a bug that isn’t in the math at all.

01 Symptom

Right-aligned values in a table overshot the column’s hairline rule by 10 to 25 points, and the affected rows felt crowded. But only some rows: specifically, only the ones that followed a row with a letterspaced chip in it — a small label drawn with extra tracking between characters, the kind of thing you’d use to make a date or code stand out. Rows without a chip above them aligned perfectly. The right-align call, drawRightString, looked correct on inspection. Nothing about its math had changed.

illustrative row — not a real document, math is the real bug

date chip
18 Jul
row total
482.15
Tc 0.0ptrow total lands exactly on the rule

02 Cause

PDF has an operator called Tc, character spacing, that a chip sets to widen the gaps between its own letters. The bug is that Tc is a graphics-state operator, not a property of the text object that set it. It survives past ET, the operator that’s supposed to end a text block, and keeps applying to every text object drawn after it on the same page — including the row total two lines down. reportlab’s stringWidth, the function that measures a string to compute where a right-aligned draw should start, has no idea any of this happened. It measures the string assuming zero spacing, so the start position it hands back is correct for a string that’s about to render wider than that measurement says.

03 Fix

The fix is one extra line, in the same text object that set the spacing, before it gets drawn:
t.setCharSpace(0.9)
t.textOut(label)
t.setCharSpace(0) # Tc survives ET — always reset
c.drawText(t)
Reset it before the object closes, not after — by the time you’re back in the caller, the state has already leaked into whatever the next draw call does.

04 Verification

The habit that caught it wasn’t reading the reportlab source, it was rasterizing. Render the page with pdftoppm, then measure the rightmost dark pixel in each row with numpy and compare it to the hairline’s end column. Aligned rows land within a pixel or two, the rounding error you’d expect from antialiasing. Anything wider than that is a real defect, not a visual quirk. Worth keeping alongside it: a guard in the row-drawing function that adds up stringWidth for the label, the gap, and the value, and raises before drawing if the sum exceeds the column’s span. That one catches copy that’s simply too long before a person notices it, which is a different failure than this one but sits in the same function.

The math was never wrong. An operator from two lines up was still switched on.

All Thinking pieces