Kirtan Soni
← All writing

A résumé that compiles

Treating a one-page résumé like a software artifact: content as data, layout as code, a renderer, and a linter that measures how well every line is used. Built iteratively with a render → screenshot feedback loop.

The premise

A résumé is usually a fragile Word document you nudge by hand until it looks right. I wanted the opposite: a source of truth in data, a deterministic renderer, and an automated check that the output is actually good — the same way you'd treat any small program. The result is a pipeline where the content lives in JSON, a Python script renders it to a pixel-faithful PDF, and a custom linter scores how much of every line the text actually fills.

The rendered one-page résumé
The output: one page, generated entirely from a JSON file. Section rules, justified bullets, live hyperlinks, embedded-font fidelity.

1 · Forensics on the original

It started from a 1.2 MB .docx. The first job was understanding how it was built. The file was a ZIP of XML, and the metadata told a story: created in Word, run through a 3-Heights PDF tool, with four weights of Carlito (a Calibri clone) embedded.The embedded fonts are nearly all of that 1.2 MB. The actual words on a one-page résumé barely register. In other words, it was a PDF that had been converted back into a DOCX.

That diagnosis mattered. A reconstructed DOCX has no real structure — no tables, just 54 paragraphs positioned with tabs and runs of manual spaces, plus converter noise like explicit strike=0 toggles on every run and words split mid-token ("Post"+"g"+"reS"+"QL").

Decision — semantic rebuild, not pixel clone. Cloning the messy XML would have reproduced the mess. Instead I rebuilt the résumé semantically: real paragraph styles, one clean bullet list, and a right-aligned tab stop for dates instead of dozens of literal spaces. It looks the same and is actually editable.

2 · Content as data (in Markdown)

Content and layout are separated. Everything a human edits lives in resume.json; all positioning lives in build_resume.py. The early version encoded bold/italic/links as nested arrays — ugly and hard to write. So inline formatting became Markdown, parsed by the renderer.

{ "header": "**Production Engineer** at **Meta**  *Menlo Park, CA*",
  "date": "May 2025 - Present",
  "bullets": [
    "Owned a CI pipeline processing 100K+ diffs/day ... gated on Meta's
     [Diff Risk Score](https://engineering.fb.com/...) so each changeset ran
     only the checks likely to catch its regressions."
  ] }
Decision — Markdown strings, parsed in the script. **bold**, *italic*, [text](url) become real docx runs and hyperlinks. It's diff-friendly, trivial for a human or an LLM to write, and keeps the JSON flat.

3 · The render pipeline & the screenshot loop

The renderer uses python-docx. To see the result, the document is converted with headless LibreOffice and rasterized — so every change can be inspected visually, not just asserted in code.

resume.json python-docx .docx LibreOffice .pdf pdftoppm .png preview

One command (./build.sh) runs the whole chain. The PNG is the feedback signal: catch a left-aligned title, a two-page spill, or a stray underline by looking, then fix the data and re-run.

4 · The interesting part: a line-utilization linter

A bullet with five words stranded on its own line wastes space. I wanted a number for it: 100% = the line is full, 0% = blank. And a rule — never split a bullet to fix it; if it wraps to two lines, report both lines so the wording can be adjusted.

The naïve approach is "average character width." That's wrong — proportional fonts vary widely.In Carlito a "W" is roughly three times the width of an "i". An average tells you nothing about either. Instead the linter reads Carlito's real per-glyph advance widths from the embedded TTF, sums them, and greedily wraps words exactly the way Word/LibreOffice does. Utilization is then natural_text_width / available_width.

Decision — calibrate against the real renderer, not a guess. The one empirical constant (the column width in points) is tuned by reading the actual wrap points from pdftotext -layout of the rendered PDF and sweeping until the model's predicted line breaks match. Final fit: bullets 18/18, full-width lines 10/10. This is the "screenshot loop" done precisely — exact wrap points instead of counting pixels.

A spot-check against the renderer's own word coordinates confirmed the model is accurate to within a percentage point:

Last lineRendered (actual)Linter predicted
"autonomously."13%12%
"catch regressions given its risk profile."31%30%
the "Trust but Canary" line80%81%

5 · Using the linter to optimize

With a trustworthy number, rewriting bullets becomes a loop instead of guesswork: write a draft, read its last-line utilization, add a concrete detail or trim to one line, repeat. The verdict keys on the last line — earlier lines justify to 100% in the render, so only the trailing line can waste space.

# before — most bullets end with a stranded line
[FAIL] AI & Agentic Systems > bullet 3   util=[94% 12%]   "...autonomously."
[FAIL] Testing & Release Infra > bullet 1   util=[98% 97% 30%]
[FAIL] Open-Source > Spotify SDK > bullet 1   util=[99% 10%]

# after — every last line now 82–100% full, still one page
[PASS] AI & Agentic Systems > bullet 3   util=[96% 83%]
[PASS] Testing & Release Infra > bullet 1   util=[97% 88%]
[PASS] Open-Source > Spotify SDK > bullet 1   util=[97% 83%]
Rendered bullets with full line utilization
The optimized bullets: each one runs close to the right margin on its last line — no stranded words, no wasted vertical space.

6 · From one résumé to a directory of them

One résumé became a reusable Claude Code skill. The insight: you apply to many jobs, but you shouldn't rebuild from scratch each time. The skill keeps a directory of versions, each tagged with the job it was used for, and exposes three flows:

FlowWhat it does
matchTriages saved résumés against a job posting, scores each on experience / projects / technologies relevance, returns a ranked recommendation.
createTailors a new draft from the closest match, renders + lints it, and leaves it in a temp area — nothing is committed yet.
saveOnly on confirmation, promotes the draft into the store and records the job it was applied with.
Decision — weighted, agent-judged matching. Score = 0.6·experience + 0.3·projects + 0.1·technologies. Experience dominates; the tech stack is the lightest signal (its absence rarely disqualifies). Weights live in a config file, so they're tunable.
Decision — drafts are temporary until used. Creating a résumé never pollutes the index. A version is only saved when the user confirms they're applying with it — so the store stays an honest record of what was actually sent.

What it adds up to

The throughline is treating a document like code: a single data source, a deterministic build, a visual feedback loop, and an objective quality gate. The line-utilization linter is the part I'm most happy with — it turns a vague "this looks empty" into a calibrated number that's accurate to the renderer within a point, and that number is what makes automated rewriting and résumé-matching trustworthy.

Stack: Python · python-docx · LibreOffice (headless) · poppler (pdftotext/pdftoppm) · fontTools · embedded Carlito.
Layout: resume.json (content) · build_resume.py (renderer + Markdown parser) · validator/ (line-utilization linter + calibration) · build.sh (the render→screenshot loop) · ~/.claude/skills/resume-directory/ (the match/create/save skill).

Tags: Python, Tooling, LLM, Claude, Résumé