#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = ["Markdown==3.10.3", "playwright==1.62.0"] # /// """Generate a versioned HTML/PDF resume pair from Markdown. Install: python -m pip install Markdown==3.10.3 playwright==1.62.0 python -m playwright install chromium Run: python scripts/generate_resume.py python scripts/generate_resume.py --version 2026-09 --browser msedge The template and print styles live in this file. The original PDF is a layout reference only; all generated resume content comes from the Markdown input. """ import argparse from copy import deepcopy from html import escape import hashlib from pathlib import Path import re import sys import tempfile import xml.etree.ElementTree as ET ROOT = Path(__file__).resolve().parents[1] DEFAULT_SOURCE = ROOT / "Alexander Herlan Resume 2024.md" CSS = """ @page { size: Letter; margin: 50pt 55.5pt 50pt 57.75pt; } * { box-sizing: border-box; } html { background: #e8e8e8; } body { width: 612pt; margin: 24px auto; padding: 50pt 55.5pt 50pt 57.75pt; background: white; color: #111; font: 10pt/1.2 Lato, Arial, sans-serif; -webkit-print-color-adjust: exact; print-color-adjust: exact; } header { display: grid; grid-template-columns: 166.5pt 1fr; gap: 18pt; min-height: 124pt; break-inside: avoid; } h1, h2, h3, h5, .contact-name { font-family: Raleway, Arial, sans-serif; } h1 { font-size: 24pt; line-height: 1.16; margin: 0 0 5pt; } .subtitle { font-size: 16pt; font-weight: bold; line-height: 1.2; color: #38761d; margin: 0; } .subtitle > span { display: block; } .gradient-word { white-space: nowrap; } .contact { border-top: 3pt solid #000; padding-top: 9pt; } .contact-name { font-size: 12pt; font-weight: bold; margin: 0 0 3pt; } .contact p { margin: 0 0 7pt; } .contact-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 9pt 10pt; margin-top: 10pt; } .contact-grid a { grid-column: span 2; display: block; min-width: 0; border-left: 2pt solid #d6e7cb; padding-left: 7pt; font-size: 9pt; line-height: 1.3; } .contact-grid a.direct { grid-column: span 3; } .contact-label { display: block; color: #72796d; font-size: 7pt; letter-spacing: .8pt; text-transform: uppercase; margin-bottom: 2pt; } .contact-value { display: inline-block; } a { color: #38761d; text-decoration: none; overflow-wrap: anywhere; } section { margin: 0 0 18pt 184.5pt; } .section-start { display: grid; grid-template-columns: 166.5pt minmax(0, 1fr); column-gap: 18pt; margin-left: -184.5pt; break-inside: avoid; break-after: avoid; } .section-start > h2 + * { border-top: 3pt solid #000; padding-top: 10pt; } h2 { width: 166.5pt; font-size: 12pt; line-height: 1.2; margin: 0; } h2::before { content: ''; display: block; width: 12pt; border-top: 1.5pt solid #111; margin-bottom: 12pt; } h3 { font-size: 12pt; line-height: 1.2; margin: 16pt 0 4pt; } .section-start > h3 { margin-top: 0; } h4 { color: #666; font-size: 10pt; margin: 8pt 0 4pt; } h5, h6 { font-size: 11pt; margin: 12pt 0 4pt; } h3, h4, h5, h6 { break-after: avoid; } p { margin: 0 0 6pt; orphans: 3; widows: 3; } p.dates { color: #666; font-size: 9pt; text-transform: uppercase; margin-bottom: 8pt; break-after: avoid; } p.dates em { font-style: normal; } ul, ol { margin: 4pt 0 8pt; padding-left: 35pt; } li { padding-left: 0; margin-bottom: 2pt; orphans: 2; widows: 2; break-inside: avoid; } li > p { margin-bottom: 2pt; } .references { list-style: none; padding: 0; } .references li { margin-bottom: 6pt; break-inside: avoid; } table { width: 100%; border-collapse: collapse; font-size: 9pt; } th, td { text-align: left; vertical-align: top; padding: 4pt; border-bottom: 1px solid #ddd; overflow-wrap: anywhere; } tr { break-inside: avoid; } img { max-width: 100%; } pre { white-space: pre-wrap; overflow-wrap: anywhere; } @media print { html { background: white; } body { width: auto; margin: 0; padding: 0; } } """ ATS_CSS = """ @page { size: Letter; margin: 40pt 43pt; } body { padding: 40pt 43pt; font-size: 10.5pt; line-height: 1.22; } header { display: block; min-height: 0; margin-bottom: 12pt; } h1 { font-size: 23pt; margin-bottom: 3pt; } .subtitle { font-size: 12pt; margin-bottom: 6pt; } .subtitle > span { display: inline-block; margin-right: 10pt; } .contact { border: 0; padding: 0; } .contact-name { display: none; } .contact p { margin-bottom: 4pt; } .contact-grid { display: flex; flex-wrap: wrap; gap: 3pt 12pt; margin-top: 0; } .contact-grid a { border: 0; padding: 0; font-size: 9.5pt; } .contact-label { display: none; } section { position: static; margin: 0 0 11pt; } .section-start { display: block; margin-left: 0; border: 0; padding: 0; } .section-start > h2 + * { border: 0; padding-top: 0; } h2 { position: static; width: auto; font-size: 11pt; text-transform: uppercase; border-bottom: 1pt solid #333; padding-bottom: 3pt; margin: 0 0 6pt; } h2::before { display: none; } h3 { font-size: 11pt; margin: 10pt 0 2pt; } h4 { font-size: 10.5pt; margin: 6pt 0 3pt; } h5, h6 { font-size: 10.5pt; margin: 6pt 0 2pt; } p { margin-bottom: 5pt; } p.dates { font-size: 9pt; margin-bottom: 4pt; } ul, ol { padding-left: 15pt; margin: 3pt 0 7pt; } li { margin-bottom: 3pt; break-inside: avoid; } @media print { body { padding: 0; } } """ def serialize(element): return ET.tostring(element, encoding="unicode", method="html") def gradient_text(text: str) -> str: """Use colored text runs so gradients print cleanly and remain selectable.""" start, end = (40, 100, 20), (138, 191, 105) result = [] offset = 0 for word in re.split(r"(\s+)", text): if word.isspace(): result.append(escape(word)) offset += len(word) continue letters = [] for character in word: fraction = offset / max(len(text) - 1, 1) color = "#" + "".join(f"{round(a + (b - a) * fraction):02x}" for a, b in zip(start, end)) letters.append(f'{escape(character)}') offset += 1 result.append('' + "".join(letters) + '') return "".join(result) def build_html(source: str, layout: str = "original") -> str: """Render Markdown in normal reading order or the original two-column layout.""" import markdown if layout not in ("ats", "original"): raise ValueError("Layout must be ats or original.") try: document = ET.fromstring( "
' + gradient_text(primary.strip()) + '' if separator: subtitle += '' + gradient_text(secondary.strip()) + "" subtitle += "
" contact_links = [] for node in intro: for parent in node.iter(): for child in list(parent): if child.tag != "a": continue href = child.get("href", "") label = ("Phone" if href.startswith("tel:") else "Email" if href.startswith("mailto:") else "Profile" if "linkedin.com/" in href else "Code" if "github.com/" in href else "Website") direct = ' class="direct"' if label in ("Phone", "Email") else "" contact_links.append( f'' f'{label}' '' + gradient_text("".join(child.itertext())) + '' ) tail = (child.tail or "").strip(" ·|\n\t") if tail: position = list(parent).index(child) if position: previous = parent[position - 1] previous.tail = (previous.tail or "") + tail else: parent.text = (parent.text or "") + tail parent.remove(child) for br in list(node): if br.tag == "br" and not (br.tail or "").strip(): node.remove(br) contact_details = "".join(serialize(node) for node in intro if "".join(node.itertext()).strip()) contact_grid = '' if contact_links else "" header = ( "' + escape(name) + "
" + contact_details + contact_grid + "