Tengis Chinzorigt
← Log

Making a one-page PDF actually fill the page

#pdf#python#tooling

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 best

Sixteen 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 overflowed

Then 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.