#!/usr/bin/env python3 """Render resume.yaml -> a .odt file. Two output flavors: --level=short (default) -> condensed single-page resume. --level=full -> detailed resume; concatenates each list with its `_full` sibling (e.g. bullets + bullets_full). Builds the ODT zip from scratch (mimetype, manifest, meta, styles, content). The ODT styling lives inline as STYLES below; content comes from resume.yaml. DOCX is produced by piping this output through LibreOffice: soffice --headless --convert-to docx .odt """ import argparse import os import yaml import zipfile HERE = os.path.dirname(os.path.abspath(__file__)) DATA = os.path.join(HERE, "resume.yaml") DEFAULT_OUT = { "short": os.path.join(HERE, "MikeEberlein_Resume.odt"), "full": os.path.join(HERE, "MikeEberlein_Resume_Detailed.odt"), } MIMETYPE = "application/vnd.oasis.opendocument.text" # Roboto faces to embed in the zip, sourced from fonts/static/. # Each tuple: (filename in Fonts/, weight attr, style attr). EMBEDDED_FONTS = [ ("Roboto-Regular.ttf", "normal", "normal"), ("Roboto-Italic.ttf", "normal", "italic"), ("Roboto-Bold.ttf", "bold", "normal"), ("Roboto-BoldItalic.ttf", "bold", "italic"), ("Roboto-Light.ttf", "300", "normal"), ("Roboto-LightItalic.ttf", "300", "italic"), ("Roboto-Medium.ttf", "500", "normal"), ] FONTS_SRC_DIR = os.path.join(HERE, "fonts", "static") def _font_manifest_entries(): rows = [] for name, _w, _s in EMBEDDED_FONTS: rows.append(f' ') return "\n".join(rows) MANIFEST = f''' {_font_manifest_entries()} ''' META = ''' Mike Eberlein Resumerender_odt.py ''' def _font_face_uris(filter_family): """Emit children for one logical family ('roboto' | 'light').""" rows = [] for name, weight, style in EMBEDDED_FONTS: # The 'Roboto Light' family only carries the Light faces; the main # 'Roboto' family carries everything else (including Medium). is_light = name.startswith("Roboto-Light") if filter_family == "light" and not is_light: continue if filter_family == "roboto" and is_light: continue rows.append( f' ' f'' f'' ) return "\n".join(rows) FONT_FACE_DECLS = f''' {_font_face_uris("roboto")} {_font_face_uris("light")} ''' STYLES = f''' {FONT_FACE_DECLS} ''' CONTENT_HEAD = ''' ''' CONTENT_TAIL = '' # ---------- XML helpers ---------- XML_ESCAPES = {"&": "&", "<": "<", ">": ">"} def xesc(s: str) -> str: return "".join(XML_ESCAPES.get(c, c) for c in s) def p(style, content): return f'{content}' def b(text): return f'{xesc(text)}' def i(text): return f'{xesc(text)}' TAB = '' def bullet(text, style="Bullet"): list_style = "L2" if style == "SubBullet" else "L1" return (f'' f'{xesc(text)}' f'') def merged(item, key, level): """Return item[key] for short, or item[key] + item[key + '_full'] for full.""" base = item.get(key) or [] if level == "full": base = list(base) + list(item.get(key + "_full") or []) return base def render(data, level="short") -> str: parts = [] h = data["header"] parts.append(p("Name", xesc(h["name"]))) contact = f'{xesc(h["tagline"])} • {xesc(h["email"])}' parts.append(p("Contact", contact)) parts.append(p("Summary", xesc(data["summary"].strip()))) parts.append(p("SectionHead", "EXPERIENCE")) for company in merged(data, "experience", level): parts.append(f'{b(company["company"])}{TAB}{xesc(company["dates"])}') for role in merged(company, "roles", level): parts.append(f'{i(role["title"])}{TAB}{i(role["dates"])}') subroles = merged(role, "subroles", level) if subroles: for sr in subroles: parts.append(f'{xesc(sr["title"])}{TAB}{i(sr["dates"])}') for blt in merged(sr, "bullets", level): parts.append(bullet(blt, style="SubBullet")) else: for blt in merged(role, "bullets", level): parts.append(bullet(blt, style="Bullet")) parts.append(p("SectionHead", "EDUCATION")) for ed in merged(data, "education", level): parts.append(f'{b(ed["school"])}{TAB}{xesc(ed["dates"])}') for blt in merged(ed, "bullets", level): parts.append(bullet(blt, style="Bullet")) parts.append(p("SectionHead", "CERTIFICATIONS")) parts.append(p("Standard", " • ".join(xesc(c) for c in merged(data, "certifications", level)))) return CONTENT_HEAD + "".join(parts) + CONTENT_TAIL def main(): ap = argparse.ArgumentParser() ap.add_argument("--level", choices=["short", "full"], default="short") ap.add_argument("--output", default=None, help="Output .odt path (default: MikeEberlein_Resume.odt / MikeEberlein_Resume_Detailed.odt)") args = ap.parse_args() out_path = args.output or DEFAULT_OUT[args.level] with open(DATA, "r", encoding="utf-8") as f: data = yaml.safe_load(f) content = render(data, level=args.level) with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z: zi = zipfile.ZipInfo("mimetype") zi.compress_type = zipfile.ZIP_STORED z.writestr(zi, MIMETYPE) z.writestr("META-INF/manifest.xml", MANIFEST) z.writestr("meta.xml", META) z.writestr("styles.xml", STYLES) z.writestr("content.xml", content) # Embed the Roboto faces inside the document for portability. for name, _w, _s in EMBEDDED_FONTS: src = os.path.join(FONTS_SRC_DIR, name) if not os.path.exists(src): raise SystemExit(f"font missing: {src}") with open(src, "rb") as f: z.writestr(f"Fonts/{name}", f.read()) print(f"wrote {out_path} (level={args.level})") if __name__ == "__main__": main()