I generate my CV from a YAML file. Two variants, different bullets selected by tag, rendered to PDF with ReportLab. The content is the source of truth and the layout is disposable — that part worked from the start.
What didn't work was the layout being a fixed size. One variant has five fewer bullets than the other, so it rendered at the same font size and then just stopped two thirds down the page. It didn't look minimal, it looked unfinished.
Fitting the type to the page
The fix is to stop hardcoding sizes and treat the type scale as the thing you solve for.
Every size in the document is a multiple of one factor k, so the whole thing scales
coherently instead of the leading drifting away from the font size. Then binary-search
k for the largest value that still fits on one page:
def fit_scale(cv, variant):
lo, hi = 0.78, 1.22
best = lo
for _ in range(16):
mid = (lo + hi) / 2
used, overflow = measure(make_story(cv, variant, mid))
if overflow or used > AVAIL_H:
hi = mid
else:
best, lo = mid, mid
return bestSixteen iterations gets you well inside a tenth of a point. The sparse variant now sets itself larger, the dense one smaller, and both land on a full page.
Measure with the real engine
The part that cost me time: I first estimated height by summing each flowable's
wrap() result plus its spacing. Close, but wrong at the margins, and "close" means a
CV that silently spills onto page two.
The fix was to stop estimating. Lay the story out in an actual Frame against a
throwaway canvas and read where the cursor ended up:
def measure(story):
c = Canvas(io.BytesIO(), pagesize=A4)
f = make_frame()
items = list(story)
f.addFromList(items, c)
used = (MARGIN_Y + AVAIL_H) - f._y
return used, bool(items) # leftover items means it overflowedThen I hit the same bug from the other direction. SimpleDocTemplate builds its own
frame with 6pt of padding on every side, and my measuring frame had none. The fitted
scale was computed against slightly more space than the document actually had, so it
overflowed anyway. Both paths now build the same zero-padding Frame from one factory
function. Measure what you render, with the thing that renders it.
Icons without breaking the text layer
I wanted contact icons in the header, but an applicant tracking system reads this file before any human does, and I didn't want to hand it a puzzle. So the icons are vector paths drawn straight to the canvas — no glyphs, no images — and every piece of actual text stays a normal string in the content stream.
I nearly broke this myself. My first LinkedIn icon drew the letters "in" with
drawString, which is real text, so extracting the PDF gave me
job@thetengis.com in linkedin.com/in/thetengis. A stray word wedged between two
fields. It's rectangles now, and I check the extracted text rather than trusting the
render.
The general lesson is one I keep relearning: if a machine reads your output before a person does, make the machine's view of it something you actually look at.