308 lines
14 KiB
Python
308 lines
14 KiB
Python
#!/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 <file>.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' <manifest:file-entry manifest:full-path="Fonts/{name}" manifest:media-type="application/x-font-ttf"/>')
|
|
return "\n".join(rows)
|
|
|
|
MANIFEST = f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.3">
|
|
<manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="application/vnd.oasis.opendocument.text"/>
|
|
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
|
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
|
|
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
|
|
{_font_manifest_entries()}
|
|
</manifest:manifest>
|
|
'''
|
|
|
|
META = '''<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:dc="http://purl.org/dc/elements/1.1/" office:version="1.3">
|
|
<office:meta><dc:title>Mike Eberlein Resume</dc:title><meta:generator>render_odt.py</meta:generator></office:meta>
|
|
</office:document-meta>
|
|
'''
|
|
|
|
def _font_face_uris(filter_family):
|
|
"""Emit <svg:font-face-uri> 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' <svg:font-face-uri xlink:href="Fonts/{name}" xlink:type="simple" '
|
|
f'loext:font-style="{style}" loext:font-weight="{weight}">'
|
|
f'<svg:font-face-format svg:string="application/x-font-ttf"/>'
|
|
f'</svg:font-face-uri>'
|
|
)
|
|
return "\n".join(rows)
|
|
|
|
FONT_FACE_DECLS = f'''<office:font-face-decls>
|
|
<style:font-face style:name="Roboto" svg:font-family="Roboto" style:font-family-generic="swiss" style:font-pitch="variable">
|
|
<svg:font-face-src>
|
|
{_font_face_uris("roboto")}
|
|
</svg:font-face-src>
|
|
</style:font-face>
|
|
<style:font-face style:name="Roboto Light" svg:font-family="'Roboto Light'" style:font-family-generic="swiss" style:font-pitch="variable">
|
|
<svg:font-face-src>
|
|
{_font_face_uris("light")}
|
|
</svg:font-face-src>
|
|
</style:font-face>
|
|
</office:font-face-decls>'''
|
|
|
|
STYLES = f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-styles
|
|
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
|
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
|
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
|
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
|
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
|
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
xmlns:loext="urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0"
|
|
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
|
office:version="1.3">
|
|
{FONT_FACE_DECLS}
|
|
<office:styles>
|
|
<style:default-style style:family="paragraph">
|
|
<style:paragraph-properties fo:hyphenate="false"/>
|
|
<style:text-properties style:font-name="Roboto" fo:font-size="9pt" fo:color="#222222"/>
|
|
</style:default-style>
|
|
<style:style style:name="Standard" style:family="paragraph" style:class="text">
|
|
<style:paragraph-properties fo:margin-top="0in" fo:margin-bottom="0in" fo:line-height="105%"/>
|
|
<style:text-properties style:font-name="Roboto" fo:font-size="9pt"/>
|
|
</style:style>
|
|
<style:style style:name="Name" style:family="paragraph" style:parent-style-name="Standard">
|
|
<style:paragraph-properties fo:text-align="center" fo:margin-bottom="0.04in"/>
|
|
<style:text-properties style:font-name="Roboto Light" fo:font-size="20pt" fo:color="#1F3864" fo:letter-spacing="0.04in"/>
|
|
</style:style>
|
|
<style:style style:name="Contact" style:family="paragraph" style:parent-style-name="Standard">
|
|
<style:paragraph-properties fo:text-align="center" fo:margin-bottom="0.06in"/>
|
|
<style:text-properties fo:font-size="9pt" fo:color="#555555"/>
|
|
</style:style>
|
|
<style:style style:name="Summary" style:family="paragraph" style:parent-style-name="Standard">
|
|
<style:paragraph-properties fo:text-align="justify" fo:margin-bottom="0.04in"/>
|
|
<style:text-properties fo:font-size="9pt"/>
|
|
</style:style>
|
|
<style:style style:name="SectionHead" style:family="paragraph" style:parent-style-name="Standard">
|
|
<style:paragraph-properties fo:margin-top="0.03in" fo:margin-bottom="0.01in" fo:border-bottom="0.5pt solid #1F3864" fo:padding-bottom="0.005in"/>
|
|
<style:text-properties style:font-name="Roboto" fo:font-size="11pt" fo:color="#1F3864" fo:font-weight="bold" fo:letter-spacing="0.02in"/>
|
|
</style:style>
|
|
<style:style style:name="JobHead" style:family="paragraph" style:parent-style-name="Standard">
|
|
<style:paragraph-properties fo:margin-top="0.03in"/>
|
|
<style:text-properties fo:font-weight="bold" fo:font-size="10pt"/>
|
|
</style:style>
|
|
<style:style style:name="RoleLine" style:family="paragraph" style:parent-style-name="Standard">
|
|
<style:paragraph-properties fo:margin-top="0.015in"/>
|
|
<style:text-properties fo:font-style="italic" fo:font-size="9pt" fo:color="#555555"/>
|
|
</style:style>
|
|
<style:style style:name="SubRole" style:family="paragraph" style:parent-style-name="Standard">
|
|
<style:paragraph-properties fo:margin-top="0.012in" fo:margin-left="0.22in"/>
|
|
<style:text-properties fo:font-size="9pt" fo:color="#222222"/>
|
|
</style:style>
|
|
<style:style style:name="Bullet" style:family="paragraph" style:parent-style-name="Standard" style:list-style-name="L1">
|
|
<style:paragraph-properties fo:margin-left="0.18in" fo:text-indent="-0.13in"/>
|
|
</style:style>
|
|
<style:style style:name="SubBullet" style:family="paragraph" style:parent-style-name="Standard" style:list-style-name="L2">
|
|
<style:paragraph-properties fo:margin-left="0.4in" fo:text-indent="-0.13in"/>
|
|
<style:text-properties fo:font-size="9pt" fo:color="#555555"/>
|
|
</style:style>
|
|
<style:style style:name="Bold" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style>
|
|
<style:style style:name="Italic" style:family="text"><style:text-properties fo:font-style="italic" fo:color="#555555"/></style:style>
|
|
|
|
<text:list-style style:name="L1">
|
|
<text:list-level-style-bullet text:level="1" text:bullet-char="•">
|
|
<style:list-level-properties text:list-level-position-and-space-mode="label-alignment">
|
|
<style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="0.18in" fo:text-indent="-0.13in" fo:margin-left="0.18in"/>
|
|
</style:list-level-properties>
|
|
</text:list-level-style-bullet>
|
|
</text:list-style>
|
|
<text:list-style style:name="L2">
|
|
<text:list-level-style-bullet text:level="1" text:bullet-char="•">
|
|
<style:list-level-properties text:list-level-position-and-space-mode="label-alignment">
|
|
<style:list-level-label-alignment text:label-followed-by="listtab" text:list-tab-stop-position="0.4in" fo:text-indent="-0.13in" fo:margin-left="0.4in"/>
|
|
</style:list-level-properties>
|
|
</text:list-level-style-bullet>
|
|
</text:list-style>
|
|
</office:styles>
|
|
<office:automatic-styles>
|
|
<style:page-layout style:name="PL1">
|
|
<style:page-layout-properties fo:page-width="8.5in" fo:page-height="11in"
|
|
fo:margin-top="0.25in" fo:margin-bottom="0.2in" fo:margin-left="0.5in" fo:margin-right="0.5in"
|
|
style:print-orientation="portrait"/>
|
|
</style:page-layout>
|
|
</office:automatic-styles>
|
|
<office:master-styles>
|
|
<style:master-page style:name="Standard" style:page-layout-name="PL1"/>
|
|
</office:master-styles>
|
|
</office:document-styles>
|
|
'''
|
|
|
|
CONTENT_HEAD = '''<?xml version="1.0" encoding="UTF-8"?>
|
|
<office:document-content
|
|
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
|
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
|
|
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
|
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
|
|
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
|
|
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
|
office:version="1.3">
|
|
<office:automatic-styles>
|
|
<style:style style:name="JobTab" style:family="paragraph" style:parent-style-name="JobHead">
|
|
<style:paragraph-properties>
|
|
<style:tab-stops><style:tab-stop style:position="7.5in" style:type="right"/></style:tab-stops>
|
|
</style:paragraph-properties>
|
|
</style:style>
|
|
<style:style style:name="RoleTab" style:family="paragraph" style:parent-style-name="RoleLine">
|
|
<style:paragraph-properties>
|
|
<style:tab-stops><style:tab-stop style:position="7.5in" style:type="right"/></style:tab-stops>
|
|
</style:paragraph-properties>
|
|
</style:style>
|
|
<style:style style:name="SubRoleTab" style:family="paragraph" style:parent-style-name="SubRole">
|
|
<style:paragraph-properties>
|
|
<style:tab-stops><style:tab-stop style:position="7.5in" style:type="right"/></style:tab-stops>
|
|
</style:paragraph-properties>
|
|
</style:style>
|
|
</office:automatic-styles>
|
|
<office:body><office:text>'''
|
|
|
|
CONTENT_TAIL = '</office:text></office:body></office:document-content>'
|
|
|
|
# ---------- 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'<text:p text:style-name="{style}">{content}</text:p>'
|
|
|
|
def b(text):
|
|
return f'<text:span text:style-name="Bold">{xesc(text)}</text:span>'
|
|
|
|
def i(text):
|
|
return f'<text:span text:style-name="Italic">{xesc(text)}</text:span>'
|
|
|
|
TAB = '<text:tab/>'
|
|
|
|
def bullet(text, style="Bullet"):
|
|
list_style = "L2" if style == "SubBullet" else "L1"
|
|
return (f'<text:list text:style-name="{list_style}"><text:list-item>'
|
|
f'<text:p text:style-name="{style}">{xesc(text)}</text:p>'
|
|
f'</text:list-item></text:list>')
|
|
|
|
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'<text:p text:style-name="JobTab">{b(company["company"])}{TAB}{xesc(company["dates"])}</text:p>')
|
|
for role in merged(company, "roles", level):
|
|
parts.append(f'<text:p text:style-name="RoleTab">{i(role["title"])}{TAB}{i(role["dates"])}</text:p>')
|
|
subroles = merged(role, "subroles", level)
|
|
if subroles:
|
|
for sr in subroles:
|
|
parts.append(f'<text:p text:style-name="SubRoleTab">{xesc(sr["title"])}{TAB}{i(sr["dates"])}</text:p>')
|
|
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'<text:p text:style-name="JobTab">{b(ed["school"])}{TAB}{xesc(ed["dates"])}</text:p>')
|
|
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()
|