feat: initial commit

This commit is contained in:
Konstantin Fickel 2026-03-05 21:03:48 +01:00
commit 6bfef61ecc
Signed by: kfickel
GPG key ID: A793722F9933C1A5
31 changed files with 1864 additions and 0 deletions

View file

382
src/cv_generator/cv.css Normal file
View file

@ -0,0 +1,382 @@
@font-face {
font-family: Fira Sans Condensed;
font-weight: 400;
src: url(fonts/FiraSansCondensed-Regular.ttf);
}
@font-face {
font-family: Fira Sans Condensed;
font-style: italic;
font-weight: 400;
src: url(fonts/FiraSansCondensed-Italic.ttf);
}
@font-face {
font-family: Fira Sans Condensed;
font-weight: 300;
src: url(fonts/FiraSansCondensed-Light.ttf);
}
@font-face {
font-family: Fira Sans Condensed;
font-weight: 700;
src: url(fonts/FiraSansCondensed-Bold.ttf);
}
@font-face {
font-family: Fira Sans Condensed;
font-weight: 500;
src: url(fonts/FiraSansCondensed-SemiBold.ttf);
}
html {
color: #393939;
font-family: Fira Sans Condensed;
font-size: 9pt;
font-weight: 400;
line-height: 1.5;
}
:root {
--page-padding: 2rem;
--sidebar-width: 50mm;
--header-height: 35mm;
--background-color: rgb(249, 249, 249);
--background-color-highlight: rgb(242, 242, 242);
}
body {
margin: 0;
}
@page letter {
padding: 80mm 25mm 20mm 20mm;
margin: 0;
@top-center {
content: element(letter_top);
}
@bottom-center {
content: element(letter_bottom);
}
}
@page cv {
background-color: var(--background-color);
margin: 0 0 0 60mm;
padding: var(--page-padding);
@left-top {
content: element(side);
}
}
div#address {
top: -50mm;
font-size: 11pt;
position: absolute;
page: letter;
font-weight: 300;
}
div#address ul {
list-style-type: none;
margin: 0;
padding: 0;
}
footer {
page: letter;
position: running(letter_bottom);
width: 210mm;
height: 25mm;
padding: 2mm 25mm 2mm 25mm;
text-align: center;
color: white;
background: linear-gradient(
135deg,
rgba(18, 78, 120, 1) 0%,
rgba(14, 44, 69, 1) 100%
);
}
footer > a {
display: inline-block;
color: inherit;
text-decoration: inherit;
}
footer > a.icon::before {
margin-left: 4mm;
display: inline-block;
width: 8pt;
height: 8pt;
background-size: 8pt 8pt;
margin-right: 1mm;
background-repeat: no-repeat;
content: "";
margin-right: 2mm;
}
div#subject {
page: letter;
position: absolute;
font-weight: 500;
top: -10mm;
font-size: 11pt;
}
div#date {
page: letter;
position: absolute;
text-align: right;
top: -20mm;
width: 165mm;
font-size: 11pt;
font-style: italic;
}
article#letter {
page: letter;
break-before: page;
text-align: justify;
font-size: 10pt;
line-height: 1.1;
}
div#letter_top {
position: running(letter_top);
width: 210mm;
height: 10mm;
background: linear-gradient(
135deg,
rgba(18, 78, 120, 1) 0%,
rgba(14, 44, 69, 1) 100%
);
}
div#appended {
font-weight: 300;
padding-top: 5mm;
opacity: 0.8;
}
header {
height: var(--header-height);
page: cv;
padding: 8mm 0 4mm 0;
}
header #name {
font-size: 40pt;
font-weight: bolder;
}
header #name #first_name {
padding-right: 6pt;
}
header #description {
font-size: 11pt;
}
aside {
position: running(side);
background: #124e78;
background: linear-gradient(
135deg,
rgba(18, 78, 120, 1) 0%,
rgba(14, 44, 69, 1) 100%
);
width: var(--sidebar-width);
height: 100%;
color: white;
padding: 5mm 5mm 5mm 5mm;
}
aside #profile_image {
border-radius: 50%;
border: 1mm solid var(--background-color);
width: 75%;
display: block;
margin: auto;
margin-bottom: 8mm;
}
aside #contact > a {
display: block;
color: inherit;
text-decoration: inherit;
}
aside #contact > a::before {
display: inline-block;
width: 8pt;
height: 8pt;
background-size: 8pt 8pt;
margin-right: 1mm;
background-repeat: no-repeat;
content: "";
}
.address_icon::before {
background-image: url(icons/house-fill.svg);
}
.phone_icon::before {
background-image: url(icons/telephone-fill.svg);
}
.mail_icon::before {
background-image: url(icons/envelope-at-fill.svg);
}
.github_icon::before {
background-image: url(icons/github.svg);
}
.linkedin_icon::before {
background-image: url(icons/linkedin.svg);
}
aside h1 {
font-size: 13pt;
padding: 2mm 0 0 0;
margin: 0;
}
aside ul {
margin: 0;
padding: 0 0 0 2mm;
}
aside .skills a {
color: inherit;
text-decoration: inherit;
}
aside .skills ul {
list-style-type: none;
margin: 0;
padding: 0;
}
aside .skills li.skilllevel_4::before {
content: "●●●◐";
padding-right: 1mm;
}
aside .skills li.skilllevel_3::before {
content: "●●●○";
padding-right: 1mm;
}
aside .skills li.skilllevel_2::before {
content: "●●○○";
padding-right: 1mm;
}
aside .skills li.skilllevel_1::before {
content: "●○○○";
padding-right: 1mm;
}
aside .skills li.other {
padding-top: 1mm;
}
aside .skills li.certificate::before {
padding-right: 1mm;
background-image: url(icons/patch-check-fill.svg);
display: inline-block;
width: 8pt;
height: 8pt;
background-size: 8pt 8pt;
margin-right: 1mm;
background-repeat: no-repeat;
content: "";
}
aside .experience {
margin-bottom: 4mm;
}
aside .experience > .title {
font-weight: 500;
}
aside .experience > .time {
text-align: right;
font-size: 7pt;
opacity: 0.5;
line-height: 1;
}
aside .experience > .description {
font-weight: 200;
font-size: 7pt;
}
.skills span.specification {
display: block;
font-weight: 200;
opacity: 0.5;
font-size: 7pt;
}
.skills span.inline-specification {
padding-left: 2mm;
font-weight: 200;
opacity: 0.5;
font-size: 7pt;
}
main section > h1 {
padding: 5mm 0 0 0;
margin: 0;
}
main .experience {
margin-bottom: 2mm;
border-radius: 2mm;
padding: 1.5mm;
background-color: var(--background-color-highlight);
padding-left: 30mm;
}
main .experience .sub_experience {
padding-top: 1mm;
}
main .experience .sub_experience .title {
font-weight: 500;
}
main .experience .sub_experience .time {
font-weight: 300;
}
main .experience ul {
margin: 0;
padding: 0mm 2mm;
}
main .experience .title {
font-weight: bolder;
display: inline-block;
}
main .experience .institution {
display: inline-block;
padding-left: 2mm;
}
main .experience .time {
position: absolute;
left: 2mm;
}
.signature {
max-width: 33%;
}

196
src/cv_generator/cv.html.j2 Normal file
View file

@ -0,0 +1,196 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<title>CV {{ cv.first_name }} {{ cv.last_name }}</title>
<meta name="author" content="{{ cv.first_name }} {{ cv.last_name }}" />
<meta name="generator" content="Weasyprint" />
<link rel="stylesheet" href="cv.css" />
</head>
<body>
<div id="letter_top"></div>
<div id="address">
<ul>
<li>{{ cv.letter.recipient.name }}</li>
<li><strong>{{ cv.letter.recipient.company }}</strong></li>
<li>{{ cv.letter.recipient.street }}</li>
<li>{{ cv.letter.recipient.city }}</li>
</ul>
</div>
<div id="subject">
{{ cv.letter.subject }}
</div>
<div id="date">{{ cv.letter.date }}</div>
<footer>
<a>{{ cv.first_name }} {{ cv.last_name }}</a>
<a class="icon address_icon">{{ cv.contact.address_full }}</a>
<a href="tel:{{ cv.contact.phone_url }}" class="icon phone_icon">{{ cv.contact.phone }}</a>
<a href="mailto:{{ cv.contact.email }}" class="icon mail_icon">{{ cv.contact.email }}</a>
</footer>
<article id="letter">
<p>{{ cv.letter.greeting }}</p>
{{ letter_body | safe }}
<p>{{ cv.letter.closing }}</p>
<img
src="{{ cv.signature }}"
class="signature"
alt="{{ cv.first_name }} {{ cv.last_name }}"
/>
</article>
<div id="appended">
Anbei: {{ cv.letter.appended }}
</div>
<header>
<div id="name">
<span id="first_name">{{ cv.first_name }}</span
><span id="last_name">{{ cv.last_name }}</span>
</div>
<div id="description">
{{ cv.description }}
</div>
</header>
<aside>
<img src="{{ cv.photo }}" id="profile_image" />
<section id="contact">
<a class="icon address_icon">{{ cv.contact.address }}</a>
<a href="tel:{{ cv.contact.phone_url }}" class="icon phone_icon">{{ cv.contact.phone }}</a>
<a href="mailto:{{ cv.contact.email }}" class="icon mail_icon">{{ cv.contact.email }}</a>
<a href="{{ cv.contact.github_url }}" class="icon github_icon">{{ cv.contact.github }}</a>
<a href="{{ cv.contact.linkedin_url }}" class="icon linkedin_icon">{{ cv.contact.linkedin }}</a>
</section>
<section class="skills">
<h1>Skills</h1>
<ul>
{% for skill in cv.skills %}
<li class="{{ skill.css_class }}">
{{ skill.name }}
{% if skill.specification %}
<span class="specification">{{ skill.specification | replace('\n', '<br>') | safe }}</span>
{% endif %}
</li>
{% endfor %}
{% for cert in cv.certificates %}
<li class="certificate">
<a href="{{ cert.url }}">{{ cert.name }}</a>
</li>
{% endfor %}
{% for other in cv.other_skills %}
<li class="other">{{ other.text }}</li>
{% endfor %}
</ul>
</section>
<section class="skills">
<h1>Sprachen</h1>
<ul>
{% for lang in cv.languages %}
<li class="{{ lang.css_class }}">
{{ lang.name }}{% if lang.specification %}<span class="inline-specification">{{ lang.specification }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</section>
<section>
<h1>Sonstiges</h1>
{% for activity in cv.other_activities %}
<div class="experience">
<div class="time">{{ activity.time }}</div>
<div class="title">{{ activity.title }}</div>
{% if activity.institution %}
<div class="institution">{{ activity.institution }}</div>
{% endif %}
{% if activity.description %}
<div class="description">
{{ activity.description }}
</div>
{% endif %}
</div>
{% endfor %}
</section>
</aside>
<main>
<section id="experiences">
<h1>Arbeitserfahrung</h1>
{% for exp in cv.work_experience %}
<div class="experience">
<div class="time">{{ exp.time }}</div>
<div class="title">
{{ exp.title }}
</div>
{% if exp.institution %}
<div class="institution">{{ exp.institution }}</div>
{% endif %}
{% if exp.description %}
<div class="description">
{{ exp.description }}
</div>
{% endif %}
{% if exp.grades %}
<div class="grades">{{ exp.grades }}</div>
{% endif %}
{% for sub in exp.sub_experiences %}
<div class="sub_experience">
<div class="time">{{ sub.time }}</div>
<div class="title">
{{ sub.title }}
</div>
{% if sub.bullets %}
<ul>
{% for bullet in sub.bullets %}
<li>{{ bullet }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
</section>
<section id="education">
<h1>Ausbildung</h1>
{% for edu in cv.education %}
<div class="experience">
<div class="time">{{ edu.time }}</div>
<div class="title">{{ edu.title }}</div>
{% if edu.institution %}
<div class="institution">{{ edu.institution }}</div>
{% endif %}
{% if edu.description %}
<div class="description">
{{ edu.description }}
</div>
{% endif %}
{% if edu.grades %}
<div class="grades">{{ edu.grades }}</div>
{% endif %}
</div>
{% endfor %}
</section>
</main>
</body>
</html>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,32 @@
from pathlib import Path
import frontmatter # pyright: ignore[reportMissingTypeStubs]
import markdown
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML # pyright: ignore[reportMissingTypeStubs]
from .models import CVData
SRC_DIR = Path(__file__).resolve().parent
def generate_pdf(input_file: Path, output_file: Path | None = None) -> Path:
post = frontmatter.load(str(input_file))
cv = CVData.model_validate(post.metadata)
letter_body: str = markdown.markdown(post.content)
input_dir = Path(input_file).resolve().parent
cv.photo = str(input_dir / cv.photo)
cv.signature = str(input_dir / cv.signature)
env = Environment(loader=FileSystemLoader(str(SRC_DIR)))
template = env.get_template("cv.html.j2")
html_str = template.render(cv=cv, letter_body=letter_body)
if output_file is None:
output_file = input_file.with_suffix(".pdf")
_ = HTML(string=html_str, base_url=str(SRC_DIR)).write_pdf(output_file) # pyright: ignore[reportUnknownMemberType]
return output_file

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="white" class="bi bi-envelope-at-fill" viewBox="0 0 16 16">
<path d="M2 2A2 2 0 0 0 .05 3.555L8 8.414l7.95-4.859A2 2 0 0 0 14 2zm-2 9.8V4.698l5.803 3.546zm6.761-2.97-6.57 4.026A2 2 0 0 0 2 14h6.256A4.5 4.5 0 0 1 8 12.5a4.49 4.49 0 0 1 1.606-3.446l-.367-.225L8 9.586zM16 9.671V4.697l-5.803 3.546.338.208A4.5 4.5 0 0 1 12.5 8c1.414 0 2.675.652 3.5 1.671"/>
<path d="M15.834 12.244c0 1.168-.577 2.025-1.587 2.025-.503 0-1.002-.228-1.12-.648h-.043c-.118.416-.543.643-1.015.643-.77 0-1.259-.542-1.259-1.434v-.529c0-.844.481-1.4 1.26-1.4.585 0 .87.333.953.63h.03v-.568h.905v2.19c0 .272.18.42.411.42.315 0 .639-.415.639-1.39v-.118c0-1.277-.95-2.326-2.484-2.326h-.04c-1.582 0-2.64 1.067-2.64 2.724v.157c0 1.867 1.237 2.654 2.57 2.654h.045c.507 0 .935-.07 1.18-.18v.731c-.219.1-.643.175-1.237.175h-.044C10.438 16 9 14.82 9 12.646v-.214C9 10.36 10.421 9 12.485 9h.035c2.12 0 3.314 1.43 3.314 3.034zm-4.04.21v.227c0 .586.227.8.581.8.31 0 .564-.17.564-.743v-.367c0-.516-.275-.708-.572-.708-.346 0-.573.245-.573.791"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="white" class="bi bi-github" viewBox="0 0 16 16">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

View file

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="white" class="bi bi-house-fill" viewBox="0 0 16 16">
<path d="M8.707 1.5a1 1 0 0 0-1.414 0L.646 8.146a.5.5 0 0 0 .708.708L8 2.207l6.646 6.647a.5.5 0 0 0 .708-.708L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293z"/>
<path d="m8 3.293 6 6V13.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 13.5V9.293z"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="white" class="bi bi-linkedin" viewBox="0 0 16 16">
<path d="M0 1.146C0 .513.526 0 1.175 0h13.65C15.474 0 16 .513 16 1.146v13.708c0 .633-.526 1.146-1.175 1.146H1.175C.526 16 0 15.487 0 14.854zm4.943 12.248V6.169H2.542v7.225zm-1.2-8.212c.837 0 1.358-.554 1.358-1.248-.015-.709-.52-1.248-1.342-1.248S2.4 3.226 2.4 3.934c0 .694.521 1.248 1.327 1.248zm4.908 8.212V9.359c0-.216.016-.432.08-.586.173-.431.568-.878 1.232-.878.869 0 1.216.662 1.216 1.634v3.865h2.401V9.25c0-2.22-1.184-3.252-2.764-3.252-1.274 0-1.845.7-2.165 1.193v.025h-.016l.016-.025V6.169h-2.4c.03.678 0 7.225 0 7.225z"/>
</svg>

After

Width:  |  Height:  |  Size: 659 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="white" class="bi bi-patch-check-fill" viewBox="0 0 16 16">
<path d="M10.067.87a2.89 2.89 0 0 0-4.134 0l-.622.638-.89-.011a2.89 2.89 0 0 0-2.924 2.924l.01.89-.636.622a2.89 2.89 0 0 0 0 4.134l.637.622-.011.89a2.89 2.89 0 0 0 2.924 2.924l.89-.01.622.636a2.89 2.89 0 0 0 4.134 0l.622-.637.89.011a2.89 2.89 0 0 0 2.924-2.924l-.01-.89.636-.622a2.89 2.89 0 0 0 0-4.134l-.637-.622.011-.89a2.89 2.89 0 0 0-2.924-2.924l-.89.01zm.287 5.984-3 3a.5.5 0 0 1-.708 0l-1.5-1.5a.5.5 0 1 1 .708-.708L7 8.793l2.646-2.647a.5.5 0 0 1 .708.708"/>
</svg>

After

Width:  |  Height:  |  Size: 601 B

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="white" class="bi bi-telephone-fill" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M1.885.511a1.745 1.745 0 0 1 2.61.163L6.29 2.98c.329.423.445.974.315 1.494l-.547 2.19a.68.68 0 0 0 .178.643l2.457 2.457a.68.68 0 0 0 .644.178l2.189-.547a1.75 1.75 0 0 1 1.494.315l2.306 1.794c.829.645.905 1.87.163 2.611l-1.034 1.034c-.74.74-1.846 1.065-2.877.702a18.6 18.6 0 0 1-7.01-4.42 18.6 18.6 0 0 1-4.42-7.009c-.362-1.03-.037-2.137.703-2.877z"/>
</svg>

After

Width:  |  Height:  |  Size: 514 B

View file

@ -0,0 +1,99 @@
from pydantic import BaseModel, computed_field
class Recipient(BaseModel):
name: str
company: str
street: str
city: str
class LetterMeta(BaseModel):
recipient: Recipient
subject: str
date: str
greeting: str
closing: str
appended: str
class ContactInfo(BaseModel):
address: str
address_full: str
phone: str
phone_url: str
email: str
github: str
github_url: str
linkedin: str
linkedin_url: str
class Skill(BaseModel):
name: str
level: int
specification: str | None = None
@computed_field
@property
def css_class(self) -> str:
return f"skilllevel_{self.level}"
class Certificate(BaseModel):
name: str
url: str
class OtherSkill(BaseModel):
text: str
class Language(BaseModel):
name: str
level: int
specification: str | None = None
@computed_field
@property
def css_class(self) -> str:
return f"skilllevel_{self.level}"
class SubExperience(BaseModel):
time: str
title: str
bullets: list[str] = []
class Experience(BaseModel):
time: str
title: str
institution: str | None = None
description: str | None = None
grades: str | None = None
sub_experiences: list[SubExperience] = []
class OtherActivity(BaseModel):
time: str
title: str
institution: str | None = None
description: str | None = None
class CVData(BaseModel):
first_name: str
last_name: str
description: str
photo: str
signature: str
contact: ContactInfo
letter: LetterMeta
skills: list[Skill]
certificates: list[Certificate]
other_skills: list[OtherSkill] = []
languages: list[Language]
work_experience: list[Experience]
education: list[Experience]
other_activities: list[OtherActivity]