242 lines
12 KiB
Python
242 lines
12 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"
|
|
|
|
MANIFEST = '''<?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"/>
|
|
</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>
|
|
'''
|
|
|
|
STYLES = '''<?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:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
|
|
office:version="1.3">
|
|
<office:font-face-decls>
|
|
<style:font-face style:name="Roboto" svg:font-family="Roboto"/>
|
|
<style:font-face style:name="Roboto Light" svg:font-family="'Roboto Light'"/>
|
|
</office: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="L1">
|
|
<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>
|
|
</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"):
|
|
return (f'<text:list text:style-name="L1"><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)
|
|
print(f"wrote {out_path} (level={args.level})")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|