#!/usr/bin/env python3
"""Render resume YAML to HTML with five RenderCV-inspired themes."""
from __future__ import annotations
import html
import re
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
import yaml
SKILL_DIR = Path(__file__).parent.parent
THEME_DIR = SKILL_DIR / "references" / "themes"
SECTION_TITLES = {
"summary": "个人评价",
"experience": "工作经历",
"projects": "项目经历",
"skills": "专业技能",
"education": "教育背景",
}
THEME_ALIASES = {
"moderncv": "modern",
}
ICON_SVGS = {
"location": '',
"email": '',
"phone": '',
"website": '',
"github": '',
"linkedin": '',
}
def esc(value: object) -> str:
if value is None:
return ""
return html.escape(str(value), quote=True)
def format_top_note(theme: dict) -> str:
mode = theme.get("meta", {}).get("top_note", "english")
now = datetime.now()
if mode == "zh":
return now.strftime("更新于 %Y年%m月")
return now.strftime("Last updated in %b %Y")
def format_date(value: object) -> str:
if value is None:
return ""
text = str(value).strip()
if not text:
return ""
lowered = text.lower()
if lowered in {"present", "current", "now", "ongoing"}:
return "至今"
year_month = re.fullmatch(r"(\d{4})-(\d{2})", text)
if year_month:
return f"{year_month.group(1)}.{year_month.group(2)}"
year_only = re.fullmatch(r"(\d{4})", text)
if year_only:
return year_only.group(1)
return text
def format_date_range(start: object, end: object) -> str:
start_text = format_date(start)
end_text = format_date(end)
if start_text and end_text:
return f"{start_text} – {end_text}"
return start_text or end_text
def normalize_url(url: str) -> str:
url = (url or "").strip()
if not url:
return ""
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", url):
return url
return f"https://{url}"
def url_display(url: str, mode: str) -> str:
normalized = normalize_url(url)
if not normalized:
return ""
parsed = urlparse(normalized)
base = parsed.netloc or parsed.path
base = base.removeprefix("www.")
path = parsed.path.rstrip("/")
if mode == "full":
return f"{base}{path}"
return base
def social_link(network: str, username: str) -> str:
if network == "GitHub":
return f"https://github.com/{username}"
if network == "LinkedIn":
return f"https://linkedin.com/in/{username}"
return username
def social_display(network: str, username: str, mode: str) -> str:
if mode == "url":
return url_display(social_link(network, username), "full")
return username
def load_theme(name: str) -> dict:
theme_name = THEME_ALIASES.get(name, name)
theme_path = THEME_DIR / f"{theme_name}.yaml"
if not theme_path.exists():
available = ", ".join(sorted(path.stem for path in THEME_DIR.glob("*.yaml")))
raise ValueError(f"未知主题: {name}。可用主题: {available}")
with open(theme_path, "r", encoding="utf-8") as handle:
theme = yaml.safe_load(handle) or {}
theme.setdefault("meta", {})
theme.setdefault("fonts", {})
theme.setdefault("colors", {})
theme.setdefault("page", {})
theme.setdefault("header", {})
theme.setdefault("section", {})
theme.setdefault("entry", {})
theme["theme_name"] = theme_name
theme["variant"] = theme["meta"].get("variant", theme_name)
return theme
def make_connections(cv: dict, theme: dict) -> list[dict]:
header = theme["header"]
items = []
if cv.get("location"):
items.append({"kind": "location", "label": cv["location"], "href": ""})
if cv.get("email"):
items.append(
{
"kind": "email",
"label": cv["email"],
"href": f"mailto:{cv['email']}",
}
)
if cv.get("phone"):
phone_label = str(cv["phone"])
phone_href = "tel:" + re.sub(r"[^\d+]", "", phone_label)
items.append({"kind": "phone", "label": phone_label, "href": phone_href})
if cv.get("website"):
items.append(
{
"kind": "website",
"label": url_display(cv["website"], header.get("website_display", "domain")),
"href": normalize_url(cv["website"]),
}
)
for network in cv.get("social_networks", []):
net = network.get("network", "")
username = network.get("username", "")
if not net or not username:
continue
items.append(
{
"kind": net.lower(),
"label": social_display(net, username, header.get("social_display", "username")),
"href": social_link(net, username),
}
)
return items
def render_connections(cv: dict, theme: dict) -> str:
items = make_connections(cv, theme)
if not items:
return ""
show_icons = theme["header"].get("show_icons", False)
separator = theme["header"].get("separator", "")
rendered = []
for item in items:
icon = ""
if show_icons:
icon_markup = ICON_SVGS.get(item["kind"], ICON_SVGS.get("website", ""))
icon = f'{icon_markup}'
tag = "a" if item["href"] else "span"
href = f' href="{esc(item["href"])}"' if item["href"] else ""
rendered.append(
f'<{tag} class="connection connection--{esc(item["kind"])}"{href}>{icon}{esc(item["label"])}{tag}>'
)
if separator:
joiner = f'{esc(separator)}'
return joiner.join(rendered)
return "".join(rendered)
def render_header(cv: dict, theme: dict) -> str:
headline = esc(cv.get("headline", ""))
headline_markup = f'
{headline}
' if headline else ""
connections = render_connections(cv, theme)
connections_markup = (
f'{connections}
' if connections else ""
)
return f"""
{esc(format_top_note(theme))}
"""
def compose_primary_line(item: dict, section_name: str, variant: str) -> tuple[str, str]:
if section_name == "experience":
position = esc(item.get("position", ""))
company = esc(item.get("company", ""))
location = esc(item.get("location", ""))
if variant == "sb2nov":
primary = f"{position}" if position else f"{company}"
subtitle = f"{company}" if company and company != position else ""
return primary, subtitle
chunks = []
if position:
chunks.append(f"{position}")
if company:
chunks.append(company if not chunks else f", {company}")
if location:
chunks.append(f" – {location}" if chunks else location)
return "".join(chunks), ""
if section_name == "education":
institution = esc(item.get("institution", ""))
degree = esc(item.get("degree", ""))
area = esc(item.get("area", ""))
location = esc(item.get("location", ""))
degree_bits = " · ".join(bit for bit in [degree, area] if bit)
if variant == "sb2nov":
primary = f"{institution}"
if degree_bits:
subtitle = f"{degree_bits}"
else:
subtitle = ""
return primary, subtitle
parts = [f"{institution}" if institution else ""]
if degree_bits:
parts.append(f", {degree_bits}" if parts[0] else degree_bits)
if location:
parts.append(f" – {location}" if parts[0] or degree_bits else location)
return "".join(parts), ""
if section_name == "projects":
name = esc(item.get("name", ""))
summary = esc(item.get("summary", ""))
primary = f"{name}" if name else ""
subtitle = f"{summary}" if summary else ""
return primary, subtitle
title = esc(item.get("name", ""))
summary = esc(item.get("summary", ""))
return f"{title}" if title else "", f"{summary}" if summary else ""
def render_highlights(highlights: list) -> str:
if not highlights:
return ""
items = "\n".join(f" {esc(point)}" for point in highlights)
return f"""
"""
def render_regular_entry(item: dict, section_name: str, theme: dict) -> str:
variant = theme["variant"]
entry = theme["entry"]
layout_class = "entry--left-meta" if entry.get("layout") == "left-meta" else "entry--right-meta"
title_line, subtitle_line = compose_primary_line(item, section_name, variant)
if section_name == "experience":
meta_lines = []
if variant == "sb2nov" and item.get("location"):
meta_lines.append(esc(item["location"]))
meta_lines.append(format_date_range(item.get("start_date"), item.get("end_date")))
summary_text = esc(item.get("summary", ""))
elif section_name == "education":
meta_lines = []
if variant == "sb2nov" and item.get("location"):
meta_lines.append(esc(item["location"]))
meta_lines.append(format_date_range(item.get("start_date"), item.get("end_date")))
summary_text = esc(item.get("summary", ""))
elif section_name == "projects":
meta_lines = [format_date_range(item.get("start_date"), item.get("end_date", item.get("date")))]
summary_text = ""
else:
meta_lines = [format_date_range(item.get("start_date"), item.get("end_date", item.get("date")))]
summary_text = esc(item.get("summary", ""))
meta_parts = [line for line in meta_lines if line]
if meta_parts:
meta_markup = '' + "".join(
f'{line}' for line in meta_parts
) + "
"
else:
meta_markup = ""
subtitle_markup = f'{subtitle_line}
' if subtitle_line else ""
summary_markup = f'{summary_text}
' if summary_text else ""
highlights_markup = render_highlights(item.get("highlights", []))
return f"""
{title_line}
{subtitle_markup}
{summary_markup}
{highlights_markup}
{meta_markup}
"""
def render_summary_section(items: list[str]) -> str:
paragraphs = "\n".join(f" {esc(item)}
" for item in items if item)
return f"""
{paragraphs}
"""
def render_skill_section(items: list[dict]) -> str:
rows = []
for item in items:
label = esc(item.get("label", ""))
details = esc(item.get("details", ""))
rows.append(
f' {label}:{details}
'
)
return """
{rows}
""".format(rows="\n".join(rows))
def render_regular_section(items: list[dict], section_name: str, theme: dict) -> str:
entries = "\n".join(render_regular_entry(item, section_name, theme) for item in items)
return f"""
{entries}
"""
def render_section(title: str, items: list, section_name: str, theme: dict) -> str:
if not items:
return ""
heading = (
''
f'
{esc(title)}
'
""
)
if section_name == "summary":
body = render_summary_section(items)
elif section_name == "skills":
body = render_skill_section(items)
else:
body = render_regular_section(items, section_name, theme)
return f"""
"""
def css_variables(theme: dict) -> str:
fonts = theme["fonts"]
colors = theme["colors"]
page = theme["page"]
header = theme["header"]
section = theme["section"]
entry = theme["entry"]
connection_justify = "center" if header.get("align", "left") == "center" else "flex-start"
name_weight = str(header.get("name_weight", 700))
return f"""
:root {{
--body-font: {fonts.get("body_stack", '"Noto Sans SC","PingFang SC",sans-serif')};
--heading-font: {fonts.get("heading_stack", fonts.get("body_stack", '"Noto Sans SC","PingFang SC",sans-serif'))};
--section-font: {fonts.get("section_stack", fonts.get("heading_stack", fonts.get("body_stack", '"Noto Sans SC","PingFang SC",sans-serif')))};
--name-color: {colors.get("name", "#004f90")};
--headline-color: {colors.get("headline", colors.get("name", "#004f90"))};
--section-color: {colors.get("section", colors.get("name", "#004f90"))};
--link-color: {colors.get("links", colors.get("name", "#004f90"))};
--text: {colors.get("text", "#1f2933")};
--muted: {colors.get("muted", "#666")};
--muted-soft: {colors.get("muted_soft", "#9a9a9a")};
--rule: {colors.get("rule", "#d6dde7")};
--title-color: {colors.get("title", colors.get("text", "#111"))};
--subtitle-color: {colors.get("subtitle", colors.get("muted", "#666"))};
--bullet-color: {colors.get("bullet", colors.get("text", "#111"))};
--page-top: {page.get("top", "0.7in")};
--page-right: {page.get("right", "0.7in")};
--page-bottom: {page.get("bottom", "0.7in")};
--page-left: {page.get("left", "0.7in")};
--name-size: {header.get("name_size", "30pt")};
--headline-size: {header.get("headline_size", "10pt")};
--connections-size: {header.get("connection_size", "9.2pt")};
--section-size: {section.get("title_size", "18pt")};
--section-weight: {section.get("title_weight", 700)};
--body-size: {entry.get("body_size", "10pt")};
--meta-size: {entry.get("meta_size", "9.1pt")};
--meta-width: {entry.get("meta_width", "3.3cm")};
--entry-gap: {entry.get("gap", "0.4cm")};
--entry-column-gap: {entry.get("column_gap", "0.45cm")};
--header-align: {header.get("align", "left")};
--connection-justify: {connection_justify};
--header-gap: {header.get("gap", "0.55cm")};
--section-space: {section.get("space_above", "0.55cm")};
--bullet-indent: {entry.get("bullet_indent", "0.22cm")};
--bullet-text-gap: {entry.get("bullet_text_gap", "0.35cm")};
--highlights-top: {entry.get("highlights_top", "0.12cm")};
--bullet-gap: {entry.get("bullet_gap", "0.08cm")};
--name-weight: {name_weight};
--name-tracking: {header.get("name_tracking", "0")};
--bullet: "{entry.get("bullet", "•")}";
}}"""
BASE_CSS = """
* {
box-sizing: border-box;
}
html {
background: #fff;
}
body {
margin: 0;
background: #fff;
color: var(--text);
font-family: var(--body-font);
font-size: var(--body-size);
line-height: 1.35;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
a {
color: var(--link-color);
text-decoration: none;
}
.page {
width: 210mm;
min-height: 297mm;
margin: 10px auto 18px;
padding: var(--page-top) var(--page-right) var(--page-bottom) var(--page-left);
background: #fff;
position: relative;
}
.top-note {
position: absolute;
top: 9mm;
right: var(--page-right);
color: var(--muted-soft);
font-size: 9pt;
font-style: italic;
}
.resume-header {
text-align: var(--header-align);
margin-bottom: var(--header-gap);
}
.resume-name {
margin: 0;
font-family: var(--heading-font);
font-size: var(--name-size);
line-height: 1;
color: var(--name-color);
font-weight: var(--name-weight);
letter-spacing: var(--name-tracking);
}
.resume-headline {
margin: 0.18cm 0 0;
color: var(--headline-color);
font-size: var(--headline-size);
}
.resume-connections {
margin-top: 0.34cm;
display: flex;
flex-wrap: wrap;
gap: 0.12cm 0.32cm;
justify-content: var(--connection-justify);
align-items: center;
}
.connection {
display: inline-flex;
align-items: center;
gap: 0.14cm;
color: var(--link-color);
font-size: var(--connections-size);
}
.connection-icon {
width: 11px;
height: 11px;
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
}
.connection-icon svg {
width: 11px;
height: 11px;
display: block;
}
.connection-separator {
color: var(--muted);
font-size: var(--connections-size);
}
.section {
margin-top: var(--section-space);
}
.section-heading {
margin: 0 0 0.18cm;
page-break-after: avoid;
}
.section-title {
margin: 0;
font-family: var(--section-font);
font-size: var(--section-size);
line-height: 1.05;
font-weight: var(--section-weight);
color: var(--section-color);
}
.section-body p {
margin: 0 0 0.12cm;
}
.entry-list {
display: flex;
flex-direction: column;
gap: var(--entry-gap);
}
.entry {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--meta-width);
gap: var(--entry-column-gap);
align-items: start;
break-inside: avoid;
page-break-inside: avoid;
}
.entry--left-meta {
grid-template-columns: var(--meta-width) minmax(0, 1fr);
}
.entry--left-meta .entry-meta {
order: -1;
text-align: left;
}
.entry-main {
min-width: 0;
}
.entry-title-line {
font-size: var(--body-size);
line-height: 1.32;
}
.entry-title-line strong {
color: var(--title-color);
font-weight: 700;
}
.entry-subtitle {
margin-top: 0.04cm;
color: var(--subtitle-color);
font-size: var(--body-size);
}
.entry-summary {
margin-top: 0.06cm;
}
.entry-meta {
color: var(--muted);
font-size: var(--meta-size);
line-height: 1.26;
text-align: right;
}
.entry-meta-line {
display: block;
}
.highlights {
list-style: none;
margin: var(--highlights-top) 0 0;
padding: 0 0 0 var(--bullet-indent);
}
.highlights li {
position: relative;
padding-left: var(--bullet-text-gap);
margin-bottom: var(--bullet-gap);
line-height: 1.28;
}
.highlights li::before {
content: var(--bullet);
position: absolute;
left: 0;
top: 0;
color: var(--bullet-color);
}
.skill-list {
display: flex;
flex-direction: column;
gap: 0.08cm;
}
.skill-row {
display: flex;
gap: 0.12cm;
align-items: baseline;
line-height: 1.3;
}
.skill-label {
color: var(--title-color);
font-weight: 700;
}
@page {
size: A4;
margin: 0;
}
@media print {
html,
body {
background: #fff;
}
.page {
margin: 0;
width: auto;
min-height: auto;
box-shadow: none;
}
a {
color: inherit;
text-decoration: none;
}
* {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
"""
THEME_CSS = {
"classic": """
.theme--classic .section-heading {
border-bottom: 1px solid var(--rule);
padding-bottom: 0.08cm;
}
.theme--classic .resume-headline {
letter-spacing: 0.01em;
}
.theme--classic .entry-meta {
padding-top: 0.03cm;
}
.theme--classic .skill-row {
gap: 0.16cm;
}
""",
"modern": """
.theme--modern .resume-name {
font-weight: 400;
}
.theme--modern .resume-headline {
color: var(--muted);
}
.theme--modern .section-heading {
display: flex;
align-items: center;
gap: 0.34cm;
}
.theme--modern .section-heading::before {
content: "";
flex: none;
width: 3.7cm;
height: 0.13cm;
background: var(--section-color);
}
.theme--modern .section-title {
font-weight: 400;
}
.theme--modern .section-body {
padding-left: 3.76cm;
}
.theme--modern .entry {
grid-template-columns: var(--meta-width) minmax(0, 1fr);
}
.theme--modern .entry-meta {
order: -1;
text-align: left;
color: var(--text);
}
.theme--modern .entry-title-line,
.theme--modern .entry-subtitle,
.theme--modern .entry-summary,
.theme--modern .highlights li,
.theme--modern .skill-row,
.theme--modern .section-body p {
line-height: 1.3;
}
""",
"sb2nov": """
.theme--sb2nov .resume-name {
letter-spacing: 0.01em;
}
.theme--sb2nov .resume-headline {
color: var(--muted);
}
.theme--sb2nov .section-heading {
border-bottom: 1px solid var(--rule);
padding-bottom: 0.07cm;
}
.theme--sb2nov .entry-meta {
font-style: italic;
line-height: 1.15;
}
.theme--sb2nov .entry-subtitle {
font-style: italic;
}
.theme--sb2nov .entry-meta-line + .entry-meta-line {
margin-top: 0.04cm;
}
.theme--sb2nov .entry-title-line strong {
font-weight: 700;
}
.theme--sb2nov .skill-row {
display: block;
}
.theme--sb2nov .skill-label {
margin-right: 0.14cm;
}
""",
"engineeringclassic": """
.theme--engineeringclassic .resume-name {
font-weight: 400;
}
.theme--engineeringclassic .resume-headline {
color: var(--muted);
}
.theme--engineeringclassic .section-heading {
border-bottom: 1px solid var(--rule);
padding-bottom: 0.06cm;
}
.theme--engineeringclassic .section-title {
font-weight: 400;
}
.theme--engineeringclassic .entry-meta {
color: var(--text);
}
.theme--engineeringclassic .entry-title-line strong {
font-weight: 700;
}
""",
"engineeringresumes": """
.theme--engineeringresumes .resume-name {
font-weight: 400;
}
.theme--engineeringresumes .resume-headline {
color: var(--muted);
}
.theme--engineeringresumes .section-heading {
border-bottom: 1px solid var(--rule);
padding-bottom: 0.06cm;
}
.theme--engineeringresumes .entry-list {
gap: 0.28cm;
}
.theme--engineeringresumes .entry-meta {
color: var(--text);
line-height: 1.15;
}
.theme--engineeringresumes .skill-row {
gap: 0.16cm;
}
.theme--engineeringresumes .entry-meta-line + .entry-meta-line {
margin-top: 0.03cm;
}
.theme--engineeringresumes .highlights li::before,
.theme--sb2nov .highlights li::before {
font-size: 0.92em;
transform: translateY(-0.01em);
}
""",
}
def build_css(theme: dict) -> str:
variant = theme["variant"]
theme_css = THEME_CSS.get(variant, "")
return css_variables(theme) + BASE_CSS + theme_css
def render_document(data: dict, theme: dict) -> str:
cv = data.get("cv", {})
sections = cv.get("sections", {})
theme_name = theme["theme_name"]
font_links = theme["fonts"].get("links", [])
fonts_markup = "\n".join(
f' ' for link in font_links
)
rendered_sections = []
for key in ["summary", "education", "experience", "projects", "skills"]:
rendered_sections.append(
render_section(SECTION_TITLES[key], sections.get(key, []), key, theme)
)
css = build_css(theme)
return f"""
{esc(cv.get("name", ""))} - 简历
{fonts_markup}
{render_header(cv, theme)}
{''.join(rendered_sections)}
"""
def render_html(yaml_path: str, output_path: str, theme_name: str = "modern") -> None:
with open(yaml_path, "r", encoding="utf-8") as handle:
data = yaml.safe_load(handle) or {}
theme = load_theme(theme_name)
html_output = render_document(data, theme)
with open(output_path, "w", encoding="utf-8") as handle:
handle.write(html_output)
print(f"✓ {output_path} (主题: {theme['theme_name']})")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python3 render_html.py [输出html] [主题]")
sys.exit(1)
yaml_path = sys.argv[1]
output_path = sys.argv[2] if len(sys.argv) > 2 else str(Path(yaml_path).with_suffix(".html"))
theme_name = sys.argv[3] if len(sys.argv) > 3 else "modern"
render_html(yaml_path, output_path, theme_name)