Resume/render_odt.py

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="&apos;Roboto Light&apos;" 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 = {"&": "&amp;", "<": "&lt;", ">": "&gt;"}
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"])} &#8226; {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", " &#8226; ".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()